#!/usr/bin/env python3
"""
xtop - top for wall-clock time by Tanel Poder [tanelpoder.com]
     - Interactive terminal UI for xcapture thread activity analysis
"""

import argparse
import sys
import os
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any, Tuple
import platform
import logging
import shutil
import math

from textual import events
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, DataTable, Static, OptionList, Label, HelpPanel
from textual.widgets.option_list import Option
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer, VerticalScroll
from textual.screen import Screen, ModalScreen
from textual.reactive import reactive
from textual.message import Message
from textual import on
from textual.css.query import NoMatches

# Add parent directory to path for imports
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

# Import core components
from core import (
    XCaptureDataSource,
    QueryEngine,
    QueryParams,
    TableFormatter,
    ChartGenerator,
    NavigationState
)
from core.histogram_formatter import HistogramFormatter
from core.display import (
    compute_column_layout,
    format_value as display_format_value,
    render_block_sparkline,
)
from core.time_utils import resolve_time_range

# Import TUI components
from tui.cell_peek_modal import HistogramPeekModal
from tui.stack_peek_modal import StackPeekModal
from tui.json_viewer_modal import JSONViewerModal
from tui.value_filter_modal import ValueFilterModal, ValueEntry

# (tab functionality removed for simplicity)

__version__ = "1.0"
__author__ = "Tanel Poder"

BREADCRUMB_LABEL_WIDTH = 11


def _get_row_value(row: Dict[str, Any], key: str) -> Any:
    if key in row:
        return row[key]
    lower = key.lower()
    upper = key.upper()
    if lower in row:
        return row[lower]
    if upper in row:
        return row[upper]
    return None


def format_time_range_line(
    label: str,
    low_time: Optional[datetime],
    high_time: Optional[datetime],
    *,
    label_width: Optional[int] = None,
) -> str:
    """Format a human-readable time range line with optional label padding."""

    label_text = f"{label}:"
    if label_width:
        label_text = label_text.ljust(label_width)

    if low_time and high_time:
        try:
            mins = int((high_time - low_time).total_seconds() // 60)
        except Exception:
            mins = None
        suffix = f" ({mins} min)" if mins is not None else ""
        return f"{label_text} {low_time.strftime('%Y-%m-%d %H:%M:%S')} -> {high_time.strftime('%Y-%m-%d %H:%M:%S')}{suffix}"
    return f"{label_text} Unknown"


def normalize_window_bounds(
    low: Optional[datetime],
    high: Optional[datetime],
    *,
    clamp_low: Optional[datetime] = None,
    clamp_high: Optional[datetime] = None,
) -> Tuple[Optional[datetime], Optional[datetime]]:
    """Normalize and optionally clamp window bounds."""

    if low and high and low > high:
        low, high = high, low

    if clamp_low and low and low < clamp_low:
        low = clamp_low
    if clamp_high and high and high > clamp_high:
        high = clamp_high

    if low and high and low > high:
        low = high

    return low, high


def compute_selection_window(
    loaded_low: Optional[datetime],
    loaded_high: Optional[datetime],
    override_low: Optional[datetime] = None,
    override_high: Optional[datetime] = None,
    *,
    default_span: timedelta = timedelta(seconds=300),
) -> Tuple[Optional[datetime], Optional[datetime]]:
    """Derive the active selection window from loaded range and overrides."""

    selection_low = loaded_low
    selection_high = loaded_high

    if loaded_low and loaded_high:
        span = loaded_high - loaded_low
        if span <= default_span:
            selection_low = loaded_low
            selection_high = loaded_high
        else:
            selection_low = max(loaded_low, loaded_high - default_span)
            selection_high = loaded_high

    if override_low is not None or override_high is not None:
        low = selection_low
        high = selection_high

        if override_low is not None:
            low = override_low
        if override_high is not None:
            high = override_high

        if override_low is None and override_high is not None:
            low = override_high - default_span
        if override_high is None and override_low is not None:
            high = override_low + default_span

        selection_low, selection_high = normalize_window_bounds(
            low,
            high,
            clamp_low=loaded_low,
            clamp_high=loaded_high,
        )

    if selection_low and selection_high and selection_low >= selection_high:
        raise ValueError("Selection window is empty or invalid")

    return selection_low, selection_high


def resolve_active_time_range(
    selection_enabled: bool,
    selection_low: Optional[datetime],
    selection_high: Optional[datetime],
    loaded_low: Optional[datetime],
    loaded_high: Optional[datetime],
) -> Tuple[Optional[datetime], Optional[datetime]]:
    """Return the effective time range for queries based on selection toggle."""

    if selection_enabled and selection_low and selection_high:
        return selection_low, selection_high
    return loaded_low, loaded_high


def build_avg_thr_timeline_line(
    query_engine: QueryEngine,
    *,
    where_clause: Optional[str],
    low_time: Optional[datetime],
    high_time: Optional[datetime],
    max_width: Optional[int],
    logger: Optional[logging.Logger] = None,
    highlight_low: Optional[datetime] = None,
    highlight_high: Optional[datetime] = None,
    render_mode: str = "plain",
    highlight_markup: str = "bold yellow",
    highlight_ansi_color: str = "\033[1;33m",
) -> str:
    """Return a unicode sparkline representing avg_threads over time."""

    label = "AvgThreads: "
    if not low_time or not high_time:
        return f"{label}(time range unavailable)"

    timeline_params = QueryParams(
        where_clause=where_clause or "1=1",
        group_cols=['yyyy', 'mm', 'dd', 'hh', 'mi'],
        low_time=low_time,
        high_time=high_time,
        limit=None,
    )

    try:
        result = query_engine.execute_with_params(
            timeline_params,
            debug=False,
            latency_columns=None,
        )
        rows = result.data or []
    except Exception as exc:  # pragma: no cover - defensive
        if logger:
            logger.debug(f"Timeline query failed: {exc}")
        return f"{label}(error fetching timeline)"

    if not rows:
        return f"{label}(no samples)"

    timeline_map: Dict[Tuple[int, int, int, int, int], float] = {}
    for row in rows:
        try:
            year = int(_get_row_value(row, 'YYYY'))
            month = int(_get_row_value(row, 'MM'))
            day = int(_get_row_value(row, 'DD'))
            hour = int(_get_row_value(row, 'HH'))
            minute = int(_get_row_value(row, 'MI'))
        except (TypeError, ValueError):
            continue

        avg_thr_val = _get_row_value(row, 'avg_threads')
        if avg_thr_val is None:
            samples = _get_row_value(row, 'samples')
            if samples is None:
                avg_thr = 0.0
            else:
                try:
                    avg_thr = float(samples) / 60.0
                except Exception:
                    avg_thr = 0.0
        else:
            try:
                avg_thr = float(avg_thr_val)
            except Exception:
                avg_thr = 0.0

        timeline_map[(year, month, day, hour, minute)] = avg_thr

    if not timeline_map:
        return f"{label}(no samples)"

    values: List[float] = []
    current = low_time.replace(second=0, microsecond=0)
    end_time = high_time.replace(second=0, microsecond=0)
    if end_time < high_time:
        end_time += timedelta(minutes=1)

    while current < end_time:
        key = (current.year, current.month, current.day, current.hour, current.minute)
        values.append(timeline_map.get(key, 0.0))
        current += timedelta(minutes=1)

    spark_width = max(0, (max_width or 0) - len(label)) or 60
    if spark_width <= 0:
        spark_width = 10

    sparkline = render_block_sparkline(values, max_chars=spark_width)
    if not sparkline:
        return f"{label}(no samples)"

    timeline_span = (high_time - low_time).total_seconds()
    highlight_span = None
    if highlight_low and highlight_high and timeline_span > 0:
        start_sec = (highlight_low - low_time).total_seconds()
        end_sec = (highlight_high - low_time).total_seconds()

        # Clamp to timeline bounds
        start_sec = max(0.0, min(timeline_span, start_sec))
        end_sec = max(0.0, min(timeline_span, end_sec))

        if end_sec > start_sec:
            highlight_span = (start_sec, end_sec)

    if highlight_span and render_mode in {"markup", "ansi"}:
        start_sec, end_sec = highlight_span
        length = len(sparkline)
        if length > 0 and timeline_span > 0:
            start_ratio = start_sec / timeline_span
            end_ratio = end_sec / timeline_span

            start_idx = int(math.floor(start_ratio * length))
            end_idx = int(math.ceil(end_ratio * length))

            if end_idx <= start_idx:
                end_idx = min(length, start_idx + 1)

            start_idx = max(0, min(length - 1, start_idx))
            end_idx = max(1, min(length, end_idx))

            if render_mode == "markup":
                open_tag = f"[{highlight_markup}]"
                close_tag = f"[/{highlight_markup}]"
                sparkline = (
                    sparkline[:start_idx]
                    + open_tag
                    + sparkline[start_idx:end_idx]
                    + close_tag
                    + sparkline[end_idx:]
                )
            elif render_mode == "ansi" and highlight_ansi_color:
                reset = "\033[0m"
                sparkline = (
                    sparkline[:start_idx]
                    + highlight_ansi_color
                    + sparkline[start_idx:end_idx]
                    + reset
                    + sparkline[end_idx:]
                )

    return f"{label}{sparkline}"


class FilterMenuScreen(ModalScreen):
    """Modal screen for include/exclude filter selection"""
    
    CSS = """
    FilterMenuScreen {
        align: center middle;
    }
    
    #filter-container {
        background: $panel;
        border: thick $primary;
        padding: 1;
        width: 40;
        height: 10;
    }
    
    #filter-title {
        text-align: center;
        text-style: bold;
        margin-bottom: 1;
    }
    
    OptionList {
        height: 4;
    }
    """
    
    def __init__(self, column: str, value: Any, **kwargs):
        """Initialize filter menu with column and value"""
        super().__init__(**kwargs)
        self.column = column
        self.value = value
        self.choice = None
    
    def compose(self) -> ComposeResult:
        """Create the menu UI"""
        with Container(id="filter-container"):
            yield Label(f"Filter: {self.column} = {self.value}", id="filter-title")
            option_list = OptionList(
                Option(f"Include {self.value}", id="include"),
                Option(f"Exclude {self.value}", id="exclude"),
            )
            option_list.focus()
            yield option_list
    
    @on(OptionList.OptionSelected)
    def on_option_selected(self, event: OptionList.OptionSelected) -> None:
        """Handle option selection"""
        # Stop event propagation to prevent it from reaching parent screen
        event.stop()
        
        self.choice = "exclude" if event.option_id == "exclude" else "include"
        self.dismiss(self.choice)
    
    def on_key(self, event: events.Key) -> None:
        """Handle key events in the modal"""
        if event.key == "escape":
            event.stop()  # Stop event propagation
            self.dismiss(None)


class LatencyColumnsScreen(ModalScreen):
    """Modal screen for selecting latency/aggregate columns to display"""
    
    CSS = """
    LatencyColumnsScreen {
        align: center middle;
    }
    
    #latency-container {
        background: $panel;
        border: thick $primary;
        padding: 1;
        width: 70;
        height: 24;
        max-height: 90%;
    }
    
    #latency-title {
        text-align: center;
        text-style: bold;
        margin: 0 0 1 0;
        height: 1;
    }
    
    #current-latency {
        margin: 0 0 1 0;
        color: $text-muted;
        height: 2;
    }
    
    #search-display {
        margin: 0 0 0 0;
        color: $text-muted;
        text-align: center;
        height: 1;
    }
    
    #instructions {
        margin: 0 0 1 0;
        text-align: center;
        color: $text-muted;
        height: 1;
    }
    
    OptionList {
        height: 1fr;
        scrollbar-size: 1 1;
    }
    """
    
    def __init__(self, available_columns: Dict[str, List[str]], selected_columns: List[str], 
                 query_engine=None, **kwargs):
        """Initialize latency columns menu"""
        super().__init__(**kwargs)
        # Build unified list from available_columns dict
        self.all_columns = []
        
        # Map categories to source labels
        source_map = {
            "System Call Latency": "syscall",
            "I/O Latency": "iorq",
            "Histograms": "histogram"
        }
        
        for category, columns in available_columns.items():
            source_label = source_map.get(category, category.lower())
            for col in columns:
                display_name = f"{col.lower()} ({source_label})"
                self.all_columns.append((col, display_name, col))
        
        # Sort alphabetically by display name
        self.all_columns.sort(key=lambda x: x[1].lower())
        
        self.selected_columns = set(selected_columns)
        self.query_engine = query_engine
        self.search_pattern = ""
    
    def compose(self) -> ComposeResult:
        """Create the latency columns UI"""
        with Container(id="latency-container"):
            yield Label("Select Latency/Aggregate Columns", id="latency-title")
            
            # Show current selection
            current_text = "Current: " + ", ".join(sorted(self.selected_columns)) if self.selected_columns else "Current: None"
            yield Label(current_text, id="current-latency")
            
            # Show search pattern
            yield Label("Type to search, BACKSPACE to clear", id="search-display")
            
            yield Label("Use SPACE to toggle, ENTER to apply, ESC to cancel", id="instructions")
            
            # Create option list - will be populated by on_mount
            option_list = OptionList()
            option_list.focus()
            yield option_list
    
    def on_mount(self) -> None:
        """Called when the widget is mounted to the app."""
        # Populate the initial column list after the widget is mounted
        self.refresh_column_list()
    
    def refresh_column_list(self):
        """Refresh the column list based on current search pattern"""
        from core.column_utils import filter_columns_by_pattern
        
        # Get the option list widget
        try:
            option_list = self.query_one(OptionList)
        except:
            return
        
        # Clear current options
        option_list.clear_options()
        
        # Filter columns based on search pattern
        filtered_columns = filter_columns_by_pattern(self.all_columns, self.search_pattern)
        
        # Add filtered columns to the list
        for col_name, display_name, col_id in filtered_columns:
            # Check if selected
            if col_id in self.selected_columns:
                option_text = f"[x] {display_name}"
            else:
                option_text = f"[ ] {display_name}"
            
            option_list.add_option(Option(option_text, id=col_id))
        
        # Update search display
        try:
            search_label = self.query_one("#search-display", Label)
            if self.search_pattern:
                search_label.update(f"Search: '{self.search_pattern}' ({len(filtered_columns)} matches)")
            else:
                search_label.update("Type characters to search, BACKSPACE to clear")
        except:
            pass
        
        # Update current selection display
        try:
            current_label = self.query_one("#current-latency", Label)
            current_text = "Current: " + ", ".join(sorted(self.selected_columns)) if self.selected_columns else "Current: None"
            current_label.update(current_text)
        except:
            pass
    
    def on_key(self, event: events.Key) -> None:
        """Handle key presses"""
        if event.key == "escape":
            event.stop()  # Stop event propagation
            self.dismiss(None)
        elif event.key == "enter":
            event.stop()  # Stop event propagation
            # Return the selected columns
            self.dismiss(list(self.selected_columns))
        elif event.key == "backspace":
            event.stop()  # Stop event propagation
            # Remove last character from search pattern
            if self.search_pattern:
                self.search_pattern = self.search_pattern[:-1]
                self.refresh_column_list()
        elif event.key == "space":
            event.stop()  # Stop event propagation
            # Toggle the current selection
            option_list = self.query_one(OptionList)
            highlighted_index = option_list.highlighted
            if highlighted_index is not None:
                try:
                    option = option_list.get_option_at_index(highlighted_index)
                    col_id = option.id
                    
                    # Skip headers
                    if col_id.startswith('_header_'):
                        return
                    
                    # Toggle selection (keep original case)
                    if col_id in self.selected_columns:
                        self.selected_columns.remove(col_id)
                    else:
                        self.selected_columns.add(col_id)
                    
                    # Get current scroll position to maintain it
                    current_scroll = option_list.scroll_offset
                    
                    # Refresh the list to update checkbox display
                    self.refresh_column_list()
                    
                    # Restore the highlighted position and scroll
                    option_list.highlighted = highlighted_index
                    if current_scroll:
                        option_list.scroll_to(current_scroll.x, current_scroll.y, animate=False)
                    
                except Exception:
                    pass
                
                # Prevent navigation on space
                event.prevent_default()
        elif event.character and event.character.isprintable():
            # Add character to search pattern
            event.stop()  # Stop event propagation
            self.search_pattern += event.character
            self.refresh_column_list()


class GroupingMenuScreen(ModalScreen):
    """Modal screen for selecting group-by columns with search functionality"""
    
    CSS = """
    GroupingMenuScreen {
        align: center middle;
    }
    
    #grouping-container {
        background: $panel;
        border: thick $primary;
        padding: 1;
        width: 70;
        height: 24;
        max-height: 90%;
    }
    
    #grouping-title {
        text-align: center;
        text-style: bold;
        margin: 0 0 1 0;
        height: 1;
    }
    
    #current-grouping {
        margin: 0 0 1 0;
        color: $text-muted;
        height: 2;
    }
    
    #search-display {
        margin: 0 0 0 0;
        color: $text-muted;
        text-align: center;
        height: 1;
    }
    
    #instructions {
        margin: 0 0 1 0;
        text-align: center;
        color: $text-muted;
        height: 1;
    }
    
    OptionList {
        height: 1fr;
        scrollbar-size: 1 1;
    }
    
    #button-container {
        align: center middle;
        margin-top: 1;
        height: 3;
    }
    """
    
    def __init__(self, columns_by_source: Dict[str, List[str]], current_columns: List[str], 
                 query_engine=None, initial_highlight: Optional[str] = None, **kwargs):
        """Initialize grouping menu with columns grouped by source and current columns"""
        super().__init__(**kwargs)
        from core.column_utils import get_unified_column_list
        
        self.columns_by_source = columns_by_source
        self.current_columns = current_columns  # Keep as list to preserve order
        self.query_engine = query_engine  # For accessing DATA_SOURCES
        # Store selected columns in lowercase for case-insensitive comparison
        self.selected_columns = set(col.lower() for col in current_columns)
        # But also keep original case mapping
        self.selected_columns_original = {col.lower(): col for col in current_columns}
        self.initial_highlight = initial_highlight  # Column to highlight initially
        self.choice_made = False
        
        # Build unified column list
        self.all_columns = get_unified_column_list(columns_by_source, query_engine)
        
        # Search pattern
        self.search_pattern = ""
    
    def compose(self) -> ComposeResult:
        """Create the grouping UI"""
        with Container(id="grouping-container"):
            yield Label("Select Grouping Columns", id="grouping-title")
            
            # Show current grouping
            current_text = "Current: " + ", ".join(self.current_columns) if self.current_columns else "Current: None"
            yield Label(current_text, id="current-grouping")
            
            # Show search pattern
            yield Label("Type to search, BACKSPACE to clear", id="search-display")
            
            yield Label("Use SPACE to toggle, ENTER to apply, ESC to cancel", id="instructions")
            
            # Create option list - will be populated by on_mount
            option_list = OptionList()
            
            option_list.focus()
            yield option_list
    
    def on_mount(self) -> None:
        """Called when the widget is mounted to the app."""
        # Populate the initial column list after the widget is mounted
        self.refresh_column_list()
    
    def refresh_column_list(self):
        """Refresh the column list based on current search pattern"""
        from core.column_utils import filter_columns_by_pattern
        
        # Get the option list widget
        try:
            option_list = self.query_one(OptionList)
        except:
            return
        
        # Clear current options
        option_list.clear_options()
        
        # Filter columns based on search pattern
        filtered_columns = filter_columns_by_pattern(self.all_columns, self.search_pattern)
        
        # Add filtered columns to the list
        initial_index = None
        for idx, (col_name, display_name, col_id) in enumerate(filtered_columns):
            # Check if selected (case-insensitive)
            if col_id.lower() in self.selected_columns:
                option_text = f"[x] {display_name}"
            else:
                option_text = f"[ ] {display_name}"
           
            option_list.add_option(Option(option_text, id=col_id))
            
            # Track the index of the column to highlight
            if self.initial_highlight and col_id == self.initial_highlight:
                initial_index = idx
        
        # Set initial highlight if found
        if initial_index is not None:
            option_list.highlighted = initial_index
        
        # Update search display
        try:
            search_label = self.query_one("#search-display", Label)
            if self.search_pattern:
                search_label.update(f"Search: '{self.search_pattern}' ({len(filtered_columns)} matches)")
            else:
                search_label.update("Type to search, BACKSPACE to clear")
        except:
            pass
    
    def on_key(self, event: events.Key) -> None:
        """Handle key presses"""
        if event.key == "escape":
            event.stop()  # Stop event propagation
            self.dismiss(None)
        elif event.key == "enter":
            event.stop()  # Stop event propagation
            # Apply the selection
            if self.selected_columns:  # Ensure at least one column selected
                self.choice_made = True
                # Build result from selected columns, preserving original case
                result_columns = []
                
                # First, preserve any originally selected columns in their order
                for col in self.current_columns:
                    if col.lower() in self.selected_columns:
                        result_columns.append(col)
                
                # Then add any newly selected columns
                for col_lower in self.selected_columns:
                    # Get the original case version
                    original_col = self.selected_columns_original.get(col_lower, col_lower)
                    
                    # Remove any prefix (e.g., "samples.tid" -> "tid")
                    if '.' in original_col:
                        base_col = original_col.split('.')[1]
                    else:
                        base_col = original_col
                    
                    # Check if already added (case-insensitive)
                    already_added = False
                    for res_col in result_columns:
                        if res_col.lower() == base_col.lower():
                            already_added = True
                            break
                    
                    if not already_added:
                        result_columns.append(base_col)
                
                self.dismiss(result_columns)
            else:
                # Don't allow empty selection
                self.notify("At least one column must be selected", severity="warning")
        elif event.key == "backspace":
            event.stop()  # Stop event propagation
            # Remove last character from search pattern
            if self.search_pattern:
                self.search_pattern = self.search_pattern[:-1]
                self.refresh_column_list()
        elif event.key == "space":
            event.stop()  # Stop event propagation
            # Toggle the current selection
            option_list = self.query_one(OptionList)
            # Get the currently highlighted index
            highlighted_index = option_list.highlighted
            if highlighted_index is not None:
                # Get the option at this index
                try:
                    option = option_list.get_option_at_index(highlighted_index)
                    col_id = option.id
                    
                    # Skip headers
                    if col_id.startswith('_header_'):
                        return
                    
                    # Toggle selection (case-insensitive)
                    col_lower = col_id.lower()
                    if col_lower in self.selected_columns:
                        self.selected_columns.remove(col_lower)
                        if col_lower in self.selected_columns_original:
                            del self.selected_columns_original[col_lower]
                    else:
                        self.selected_columns.add(col_lower)
                        self.selected_columns_original[col_lower] = col_id
                    
                    # Get current scroll position to maintain it
                    current_scroll = option_list.scroll_offset
                    
                    # Refresh the list to update checkbox display
                    self.refresh_column_list()
                    
                    # Restore the highlighted position and scroll
                    option_list.highlighted = highlighted_index
                    if current_scroll:
                        option_list.scroll_to(current_scroll.x, current_scroll.y, animate=False)
                    
                except Exception as e:
                    # If there's an issue with the API, log it
                    pass
                
                # Prevent navigation on space
                event.prevent_default()
        elif event.character and event.character.isprintable():
            # Add character to search pattern
            event.stop()  # Stop event propagation
            self.search_pattern += event.character
            self.refresh_column_list()
    
    @on(OptionList.OptionSelected)
    def on_option_selected(self, event: OptionList.OptionSelected) -> None:
        """Handle option selection (Enter key)"""
        # Don't process this event - let the on_key handler deal with Enter
        # This prevents double-handling of the Enter key
        event.prevent_default()
        event.stop()


class XTopTUI(App):
    """xtop app"""
   
    TITLE = "xtop: top for wall-clock time"
    SUB_TITLE = "Dimensional performance analysis of thread-level activity"
    
    CSS = """
    Screen {
        layout: vertical;
    }
    
    DataTable {
        scrollbar-size: 1 1;
        height: 1fr;
        border: solid green;
    }
    
    DataTable > .datatable--header {
        background: $primary;
        color: $text;
    }
    
    DataTable > .datatable--cursor {
        background: $secondary;
    }
    
    VerticalScroll {
        height: 1fr;
    }
    
    #global-breadcrumb {
        dock: top;
        height: 6;
        padding: 0 1;
        border: solid cyan;
        background: $primary;
        color: $text;
        text-style: bold;
    }
    
    #status {
        dock: bottom;
        height: 1;
        padding: 0 1;
        background: $surface;
    }
    
    # Tab-related CSS removed for simplicity
    """
    
    BINDINGS = [
        ("enter", "drill_down", "Drill down"),
        ("backspace", "back_out", "Remove last filter"),
        ("space", "show_filter_menu", "Filter menu"),
        ("/", "search_filter", "Search values"),
        # Tab bindings removed for simplicity
        ("r", "refresh", "Refresh"),
        ("[", "move_window_left", "Move window left"),
        ("]", "move_window_right", "Move window right"),
        ("w", "toggle_selection_window", "Toggle selection window"),
        # Removed query type selection - always uses dynamic queries
        ("g", "change_grouping", "Grouping"),
        ("u", "undo_grouping", "Undo last grouping"),
        ("l", "latency_columns", "Latency columns"),
        ("d", "remove_column", "Remove column"),
        ("<", "move_column_left", "Move column left"),
        (">", "move_column_right", "Move column right"),
        ("?", "peek_cell", "Peek cell details"),
        ("h", "toggle_help", "Keys/help panel"),
        ("ctrl+c", "quit", "Quit"),
    ]
    
    def __init__(self, datadir: str,
                 low_time: Optional[datetime] = None, high_time: Optional[datetime] = None,
                 selection_low: Optional[datetime] = None, selection_high: Optional[datetime] = None,
                 selection_enabled: bool = False,
                 debug_log: Optional[str] = None, initial_group_by: Optional[List[str]] = None,
                 append_group_by: Optional[List[str]] = None, duckdb_threads: Optional[int] = None):
        """Initialize TUI with data directory and time range"""
        super().__init__()
        self.datadir = datadir
        self.low_time = low_time
        self.high_time = high_time
        self.initial_group_by = initial_group_by
        self.append_group_by = append_group_by
        
        # Setup debug logging if requested
        self.logger = None
        if debug_log:
            logging.basicConfig(
                filename=debug_log,
                level=logging.DEBUG,
                format='%(asctime)s - %(levelname)s - %(message)s'
            )
            self.logger = logging.getLogger('xtop-tui')
            self.logger.info(f"Starting xtop-tui with datadir={datadir}")
            self.logger.info(f"Time Range: {low_time} to {high_time}")
        
        # Initialize core components
        self.data_source = XCaptureDataSource(datadir, duckdb_threads=duckdb_threads)
        self.query_engine = QueryEngine(self.data_source)
        self.formatter = TableFormatter()
        self.visualizer = ChartGenerator()
        
        # Initialize navigation and query state directly
        self.navigation = NavigationState()
        self.query_params = QueryParams()
        self.last_result = None
        self.display_columns = []
        self.selected_latency_columns = []  # Track selected latency/aggregate columns
        self.window_step = timedelta(minutes=1)
        self.selection_enabled = bool(selection_enabled)
        self.selection_low: Optional[datetime] = None
        self.selection_high: Optional[datetime] = None
        
        # If no time range specified, select latest 60 minutes of available data based on files
        if not low_time and not high_time:
            try:
                low_time, high_time = self._detect_default_time_range()
                if self.logger:
                    self.logger.info(f"Auto-selected latest 60 minutes: {low_time} to {high_time}")
            except Exception as e:
                if self.logger:
                    self.logger.warning(f"Failed to detect default time range: {e}")
        
        # Store loaded range (files/time span) and initialise selection window
        self.loaded_low = low_time
        self.loaded_high = high_time

        if selection_low is None and selection_high is None:
            try:
                selection_low, selection_high = compute_selection_window(
                    self.loaded_low,
                    self.loaded_high,
                )
            except ValueError as exc:
                raise RuntimeError(str(exc)) from exc

        self._set_selection_window(selection_low, selection_high, clamp=False)

        # Set query type to dynamic (the only type now) and time range
        self.query_params.query_type = 'dynamic'
        self._update_query_time_range()

        # Use custom group by columns if provided, otherwise use defaults
        if self.initial_group_by:
            # Standardize to lowercase for consistency
            group_cols = [col.lower() for col in self.initial_group_by]
            if self.logger:
                self.logger.info(f"Using custom GROUP BY columns: {group_cols}")
        else:
            # Start with default columns (already lowercase in DEFAULT_GROUP_COLS)
            default_cols = self.query_engine.DEFAULT_GROUP_COLS.get('dynamic', [])
            group_cols = list(default_cols)  # Make a copy
            
            # Append additional columns if -G was used
            if self.append_group_by:
                # Convert to lowercase and append
                append_cols = [col.lower() for col in self.append_group_by]
                # Only append columns that aren't already in the list
                for col in append_cols:
                    if col not in group_cols:
                        group_cols.append(col)
                if self.logger:
                    self.logger.info(f"Appending GROUP BY columns: {append_cols}")
                    self.logger.info(f"Final GROUP BY columns: {group_cols}")
            
        self.navigation.reset(group_cols)
        
        # Platform detection
        self.is_macos = platform.system() == "Darwin"
        self.cmd_key = "cmd" if self.is_macos else "ctrl"
        
        # Status message
        self.status_message = ""
        
        # Flag to prevent immediate drill-down after grouping change
        self._just_changed_grouping = False
    
    def compose(self) -> ComposeResult:
        """Create the UI layout"""
        yield Header()

        # Breadcrumb display (includes Timeline, Time Range, Filters, and Path)
        initial_breadcrumb = (
            "AvgThreads: (initialising)\n"
            f"{'TimeRange:'.ljust(BREADCRUMB_LABEL_WIDTH)} Detecting...\n"
            f"{'Window:'.ljust(BREADCRUMB_LABEL_WIDTH)} Pending...\n"
            f"{'Filters:'.ljust(BREADCRUMB_LABEL_WIDTH)} No filters applied\n"
            f"{'Path:'.ljust(BREADCRUMB_LABEL_WIDTH)} Loading..."
        )
        yield Static(initial_breadcrumb, id="global-breadcrumb")
        
        # Single data table (no tabs)
        yield DataTable(id="main-table", zebra_stripes=True)
                    
        yield Static("Ready", id="status")
        yield Footer()

    def _detect_default_time_range(self) -> Tuple[Optional[datetime], Optional[datetime]]:
        """Detect latest 60 minutes of available data by scanning samples files (csv/parquet)."""
        from pathlib import Path as _Path
        import re as _re
        datadir = _Path(self.datadir)
        candidates = []
        # Consider both parquet and csv
        for ext in ("parquet", "csv"):
            for p in datadir.glob(f"xcapture_samples_*.{ext}"):
                m = _re.search(r"xcapture_samples_(\d{4}-\d{2}-\d{2})\.(\d{2})\.", p.name + '.')
                if m:
                    date_str, hh = m.group(1), m.group(2)
                    try:
                        hour_dt = datetime.strptime(f"{date_str} {hh}", "%Y-%m-%d %H")
                        candidates.append(hour_dt)
                    except Exception:
                        continue
        if not candidates:
            return (None, None)
        latest_hour = max(candidates)
        high_time = latest_hour.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
        low_time = high_time - timedelta(hours=1)
        return (low_time, high_time)

    def _normalize_selection_window(
        self,
        low: Optional[datetime],
        high: Optional[datetime],
        *,
        clamp: bool = True,
    ) -> Tuple[Optional[datetime], Optional[datetime]]:
        """Ensure selection window ordering and clamping."""

        clamp_low = self.loaded_low if clamp else None
        clamp_high = self.loaded_high if clamp else None
        return normalize_window_bounds(low, high, clamp_low=clamp_low, clamp_high=clamp_high)

    def _set_selection_window(
        self,
        low: Optional[datetime],
        high: Optional[datetime],
        *,
        clamp: bool = True,
    ) -> None:
        """Update selection window and propagate to QueryParams."""

        self.selection_low, self.selection_high = self._normalize_selection_window(
            low,
            high,
            clamp=clamp,
        )
        self._update_query_time_range()

    def _update_query_time_range(self) -> None:
        """Keep QueryParams time filters aligned with selection window."""

        if self.query_params is None:
            return
        active_low, active_high = resolve_active_time_range(
            self.selection_enabled,
            self.selection_low,
            self.selection_high,
            self.loaded_low,
            self.loaded_high,
        )
        self.query_params.low_time = active_low
        self.query_params.high_time = active_high

    def _shift_selection_window(self, offset: timedelta) -> bool:
        """Shift the selection window by a timedelta offset.

        Returns True if the window changed, False otherwise.
        """

        if not self.selection_low or not self.selection_high:
            return False

        span = self.selection_high - self.selection_low
        if span.total_seconds() <= 0:
            return False

        new_low = self.selection_low + offset
        new_high = self.selection_high + offset

        if self.loaded_low and new_low < self.loaded_low:
            new_low = self.loaded_low
            new_high = new_low + span

        if self.loaded_high and new_high > self.loaded_high:
            new_high = self.loaded_high
            new_low = new_high - span

        # When the available data span is smaller than the window span, clamp to loaded range
        if self.loaded_low and self.loaded_high:
            available_span = self.loaded_high - self.loaded_low
            if available_span.total_seconds() > 0 and available_span < span:
                new_low = self.loaded_low
                new_high = self.loaded_high

        new_low, new_high = self._normalize_selection_window(new_low, new_high, clamp=True)

        if self.selection_low == new_low and self.selection_high == new_high:
            return False

        self._set_selection_window(new_low, new_high, clamp=True)
        return True

    def on_mount(self) -> None:
        """Initialize when app is mounted"""
        if self.logger:
            self.logger.info("App mounted, refreshing data...")
        
        # Initial data refresh with a small delay
        self.set_timer(0.1, self.refresh_data)
    
    def execute_current_query(self) -> Tuple[List[Dict[str, Any]], List[str]]:
        """Execute query and return results"""
        # Update query params with current navigation state
        self.query_params.where_clause = self.navigation.get_current_where_clause()
        self.query_params.group_cols = self.navigation.get_current_group_cols()
        
        # Set reasonable limit for TUI display
        self.query_params.limit = 100
        
        try:
            # Log query execution
            if self.logger:
                self.logger.info(f"Executing query type: {self.query_params.query_type}")
                self.logger.info(f"Where clause: {self.query_params.where_clause}")
                self.logger.info(f"Group columns: {self.query_params.group_cols}")
                self.logger.info(f"Time range: {self.query_params.low_time} to {self.query_params.high_time}")
            
            # Execute query
            result = self.query_engine.execute_with_params(
                self.query_params,
                debug=self.logger is not None,
                latency_columns=self.selected_latency_columns  # Always use latency columns with dynamic queries
            )
            
            # Log results
            if self.logger:
                self.logger.info(f"Query returned {result.row_count} rows")
                self.logger.info(f"Columns: {result.columns}")
                self.logger.info(f"Execution time: {result.execution_time:.3f}s")
                # Don't log data to keep log concise
            
            # Cache result
            self.last_result = result
            
            # Process data for display
            data = result.data
            columns = result.columns
            
            # Add visualizations for dynamic queries
            # Dynamic queries handle all visualization needs
            if False:  # Removed old query type specific code
                # Add histogram visualization
                max_samples = max([row.get('total_samples', 0) or 0 for row in data]) if data else 0
                for row in data:
                    samples = row.get('total_samples', 0) or 0
                    row['time_bar'] = self.visualizer.make_bar(samples, max_samples, width=10)
                    
                    hist_str = row.get('sclat_histogram', '')
                    row['histogram_viz'] = self.visualizer.make_histogram_with_embedded_max(hist_str)
                
                if 'time_bar' not in columns:
                    columns.append('time_bar')
                    
                if 'sclat_histogram' in columns:
                    idx = columns.index('sclat_histogram')
                    columns.insert(idx + 1, 'histogram_viz')
                    columns.remove('sclat_histogram')
                
                # Remove internal columns
                if 'global_max_bucket_time' in columns:
                    columns.remove('global_max_bucket_time')
            
            elif False:  # Removed old iolathist specific code
                # Add histogram visualization for I/O latency
                max_samples = max([row.get('total_samples', 0) or 0 for row in data]) if data else 0
                for row in data:
                    samples = row.get('total_samples', 0) or 0
                    row['time_bar'] = self.visualizer.make_bar(samples, max_samples, width=10)
                    
                    hist_str = row.get('iolat_histogram', '')
                    row['histogram_viz'] = self.visualizer.make_histogram_with_embedded_max(hist_str)
                
                if 'time_bar' not in columns:
                    columns.append('time_bar')
                    
                if 'iolat_histogram' in columns:
                    idx = columns.index('iolat_histogram')
                    columns.insert(idx + 1, 'histogram_viz')
                    columns.remove('iolat_histogram')
                
                # Remove internal columns
                if 'global_max_bucket_time' in columns:
                    columns.remove('global_max_bucket_time')
            
            # Process histogram columns (always for dynamic queries)
            if True:  # Always process for dynamic queries
                # Add time_bar visualization for dynamic queries (similar to sclathist/iolathist)
                # Check for both 'samples' (used by dynamic query) and 'total_samples' (used by others)
                if any(col in columns for col in ['samples', 'total_samples']):
                    # Find max samples value from any of the possible column names
                    max_samples = 0
                    for row in data:
                        sample_val = (row.get('samples', 0) or 
                                     row.get('total_samples', 0) or 0)
                        max_samples = max(max_samples, sample_val)
                    
                    # Generate time_bar for each row
                    for row in data:
                        samples = (row.get('samples', 0) or 
                                  row.get('total_samples', 0) or 0)
                        row['time_bar'] = self.visualizer.make_bar(samples, max_samples, width=10)
                    
                    # Insert time_bar right after avg_threads
                    if 'time_bar' not in columns:
                        if 'avg_threads' in columns:
                            idx = columns.index('avg_threads')
                            columns.insert(idx + 1, 'time_bar')
                        elif 'avg_threads' in columns:
                            idx = columns.index('avg_threads')
                            columns.insert(idx + 1, 'time_bar')
                        else:
                            columns.append('time_bar')
                
                # Process histogram columns
                for hist_col in ['sclat_histogram', 'iolat_histogram']:
                    if hist_col in columns:
                        viz_col = hist_col.replace('_histogram', '_histogram_viz')
                        for row in data:
                            hist_data = row.get(hist_col)
                            if hist_data:
                                # Convert DuckDB histogram dict to visualization
                                row[viz_col] = self._convert_histogram_to_viz(hist_data)
                            else:
                                row[viz_col] = ' ' * 26
                        
                        # Replace histogram column with visualization
                        idx = columns.index(hist_col)
                        columns[idx] = viz_col
            
            # Reorder columns
            columns = self.formatter.reorder_columns_samples_first(columns)
            
            # IMPORTANT: Store the display columns order that matches what's rendered
            self.display_columns = columns
            
            if self.logger:
                self.logger.info(f"Processed {len(data)} rows for display")
                self.logger.debug(f"Display columns: {columns}")
            
            return data, columns
            
        except Exception as e:
            error_msg = f"Query error: {str(e)}"
            self.update_status(error_msg)
            if self.logger:
                self.logger.error(f"Query execution failed: {str(e)}", exc_info=True)
            
            # Capture the error message before the exception goes out of scope
            error_str = str(e)
            
            # Show error in a modal popup after refresh
            def show_error_modal():
                from tui.error_modal import ErrorModal
                error_modal = ErrorModal(
                    title="Query Execution Error",
                    error_message=error_str,
                    details="Check the debug.log for the full SQL query and error details."
                )
                self.push_screen(error_modal)
            
            # Schedule the modal to be shown after the current refresh
            self.call_after_refresh(show_error_modal)
            
            return [], []
    
    def _convert_histogram_to_viz(self, hist_str: str, width: int = 26) -> str:
        """Convert histogram string to unicode visualization

        The histogram format is: bucket:count:time:global_max,bucket:count:time:global_max,...
        This matches the format from the original sclathist.sql
        
        Args:
            hist_str: Histogram string in bucket:count:time:max format
            width: Width of the visualization
            
        Returns:
            Unicode string visualization
        """
        # Use the existing visualizer method that handles this format
        return self.visualizer.make_histogram_with_embedded_max(hist_str)

    @staticmethod
    def _row_value(row: Dict[str, Any], key: str) -> Any:
        return _get_row_value(row, key)

    def _build_avg_thr_timeline_line(self, max_width: int) -> str:
        return build_avg_thr_timeline_line(
            self.query_engine,
            where_clause=self.navigation.get_current_where_clause(),
            low_time=self.loaded_low,
            high_time=self.loaded_high,
            max_width=max_width,
            logger=self.logger,
            highlight_low=self.selection_low if self.selection_enabled else None,
            highlight_high=self.selection_high if self.selection_enabled else None,
            render_mode="markup",
        )

    def refresh_data(self) -> None:
        """Refresh data display"""

        # Save current cursor position before refresh
        saved_cursor_row = None
        saved_cursor_col = None
        saved_cursor_col_name = None
        try:
            table = self.query_one("#main-table", DataTable)
            if table and table.cursor_coordinate:
                saved_cursor_row = table.cursor_coordinate.row
                saved_cursor_col = table.cursor_coordinate.column
                # Try to get the column name for better restoration
                if self.display_columns and saved_cursor_col < len(self.display_columns):
                    saved_cursor_col_name = self.display_columns[saved_cursor_col]
        except:
            pass
        
        # Get data
        data, columns = self.execute_current_query()
        
        # Update global breadcrumb
        try:
            breadcrumb = self.query_one("#global-breadcrumb", Static)
            
            # Get filter display and navigation path
            filter_display = self.navigation.get_filter_display()
            nav_path = self.navigation.get_breadcrumb_path()
            
            # Build time range lines
            active_low, active_high = resolve_active_time_range(
                self.selection_enabled,
                self.selection_low,
                self.selection_high,
                self.loaded_low,
                self.loaded_high,
            )
            loaded_low = self.loaded_low or active_low
            loaded_high = self.loaded_high or active_high
            time_line = format_time_range_line(
                "TimeRange",
                loaded_low,
                loaded_high,
                label_width=BREADCRUMB_LABEL_WIDTH,
            )
            if self.selection_enabled and self.selection_low and self.selection_high:
                window_line = format_time_range_line(
                    "Window",
                    self.selection_low,
                    self.selection_high,
                    label_width=BREADCRUMB_LABEL_WIDTH,
                )
            else:
                window_line = f"{'Window:'.ljust(BREADCRUMB_LABEL_WIDTH)} Disabled"
            pane_width = 0
            try:
                pane_width = getattr(breadcrumb.size, 'width', 0)
            except Exception:
                pane_width = 0
            if not pane_width:
                pane_width = getattr(self.size, 'width', 80) or 80

            inner_width = max(10, int(pane_width) - 2)
            timeline_line = self._build_avg_thr_timeline_line(inner_width)
            # Combine all into breadcrumb area (three lines)
            breadcrumb_text = (
                f"{timeline_line}\n"
                f"{time_line}\n"
                f"{window_line}\n"
                f"{'Filters:'.ljust(BREADCRUMB_LABEL_WIDTH)} {filter_display}\n"
                f"{'Path:'.ljust(BREADCRUMB_LABEL_WIDTH)} {nav_path}"
            )

            if self.logger:
                self.logger.info(f"Updating global breadcrumb with: {breadcrumb_text.replace(chr(10), ' | ')}")

            breadcrumb.update(breadcrumb_text)
            
        except Exception as e:
            if self.logger:
                self.logger.error(f"Failed to update breadcrumb: {e}", exc_info=True)
        
        # Update data table
        try:
            table = self.query_one("#main-table", DataTable)
        except Exception as e:
            if self.logger:
                self.logger.error(f"Failed to find main table: {e}")
            return
        
        # Clear and repopulate table
        table.clear(columns=True)
        
        # Store current data for cell peek functionality
        self.current_data = data
        
        if data and columns:
            if self.logger:
                self.logger.info(f"Adding {len(columns)} columns and {len(data)} rows to table")
            
            # Generate headers first
            headers = self.formatter.generate_headers(columns)
            
            layout = compute_column_layout(columns, data, headers)
            col_widths = layout.widths
            numeric_cols = layout.numeric_columns
            
            # Add columns with proper alignment and width
            for col in columns:
                header = headers.get(col, col)
                # Right-align numeric column headers to match data alignment
                if col in numeric_cols:
                    header = header.rjust(col_widths[col])
                # Check if column already exists (shouldn't happen after clear, but be safe)
                try:
                    # Pass the calculated width to ensure proper initial sizing
                    table.add_column(header, key=col, width=col_widths[col])
                    if self.logger:
                        self.logger.debug(f"Added column: {col} (header: {header}, width: {col_widths[col]})")
                except Exception as e:
                    if self.logger:
                        self.logger.warning(f"Could not add column {col}: {e}")
            
            # Add rows
            for i, row in enumerate(data):
                row_values = []
                for col in columns:
                    value = display_format_value(col, row.get(col))
                    # Right-align numeric columns
                    if col in numeric_cols:
                        value = value.rjust(col_widths[col])
                    row_values.append(value)
                table.add_row(*row_values)
                if self.logger and i < 3:  # Log first 3 rows for debugging
                    self.logger.debug(f"Added row {i}: {row_values[:3]}...")  # Log first 3 values
        
        # Force refresh the table
        table.refresh()
        
        # Debug: Check table state after adding data
        if self.logger:
            self.logger.debug(f"Table row count: {table.row_count}")
            self.logger.debug(f"Table column count: {len(table.columns)}")
            self.logger.debug(f"Table visible: {table.visible}")
            self.logger.debug(f"Table has focus: {table.has_focus}")
            self.logger.debug(f"Table size: {table.size}")
            self.logger.debug(f"Table region: {table.region}")
            
            # Check parent container
            try:
                tab_pane = table.parent
                if tab_pane:
                    self.logger.debug(f"Tab pane visible: {tab_pane.visible}")
                    self.logger.debug(f"Tab pane size: {tab_pane.size}")
                    self.logger.debug(f"Tab pane class: {tab_pane.__class__.__name__}")
            except Exception as e:
                self.logger.debug(f"Error checking parent containers: {e}")
        
        # Focus the table to enable keyboard navigation
        table.focus()
        
        # Try to force a layout update
        self.refresh(layout=True)
        
        # Restore cursor position after refresh
        if saved_cursor_row is not None and saved_cursor_col is not None:
            try:
                # Try to find the column by name first
                if saved_cursor_col_name and self.display_columns:
                    # Look for the same column name
                    if saved_cursor_col_name in self.display_columns:
                        new_col_index = self.display_columns.index(saved_cursor_col_name)
                    else:
                        # Try to find a similar column (e.g., if prefixes changed)
                        new_col_index = saved_cursor_col
                        for i, col in enumerate(self.display_columns):
                            if col.lower() == saved_cursor_col_name.lower() or \
                               (col.replace('.', '_').lower() == saved_cursor_col_name.replace('.', '_').lower()):
                                new_col_index = i
                                break
                else:
                    new_col_index = saved_cursor_col
                
                # Ensure indices are within bounds
                max_row = min(saved_cursor_row, table.row_count - 1) if table.row_count > 0 else 0
                max_col = min(new_col_index, len(table.columns) - 1) if table.columns else 0
                
                # Move cursor to saved or adjusted position
                if table.row_count > 0 and len(table.columns) > 0:
                    table.move_cursor(row=max_row, column=max_col)
            except Exception as e:
                if self.logger:
                    self.logger.debug(f"Could not restore cursor position: {e}")
        
        if len(data) > 0:
            self.update_status(f"Showing {len(data)} rows")
        else:
            status_msg = "No data to display"
            if self.query_params:
                status_msg += f" (Query: dynamic"
                if self.query_params.low_time and self.query_params.high_time:
                    status_msg += f", Time: {self.query_params.low_time.strftime('%Y-%m-%d %H:%M')} to {self.query_params.high_time.strftime('%H:%M')}"
                status_msg += ")"
            self.update_status(status_msg)
    
    def update_status(self, message: str) -> None:
        """Update status bar message"""
        status = self.query_one("#status", Static)
        status.update(message)
    
    def action_show_filter_menu(self) -> None:
        """Show filter menu for include/exclude on SPACE key"""
        if not self.last_result:
            return
        
        # Get the current table
        try:
            table = self.query_one("#main-table", DataTable)
        except Exception:
            return
        
        # Get cursor position
        if not table.cursor_coordinate:
            self.update_status("No cell selected")
            return
            
        cursor_row = table.cursor_coordinate.row
        cursor_col = table.cursor_coordinate.column
        
        # Get the column name and value
        try:
            # Get the actual value from the data
            if cursor_row < len(self.last_result.data):
                row_data = self.last_result.data[cursor_row]
                # Use display columns which match the rendered table
                columns = self.display_columns if self.display_columns else self.last_result.columns
                
                if cursor_col < len(columns):
                    col_key = columns[cursor_col]
                    value = row_data.get(col_key)
                    
                    if value is not None:
                        # Check if this column is a GROUP BY column (case-insensitive)
                        group_cols = self.navigation.get_current_group_cols()
                        col_key_lower = col_key.lower()
                        if col_key_lower not in [gc.lower() for gc in group_cols]:
                            self.update_status(f"Cannot filter on '{col_key}' - only GROUP BY columns can be filtered")
                            return
                        
                        # Show the filter menu
                        def handle_choice(choice: Optional[str]) -> None:
                            """Handle menu choice"""
                            if choice:
                                # Apply the filter
                                exclude = (choice == "exclude")
                                self.navigation.drill_down(col_key, value, exclude=exclude)
                                self.refresh_data()
                                
                                if exclude:
                                    self.update_status(f"Excluded {col_key}!={value}")
                                else:
                                    self.update_status(f"Filtered by {col_key}={value}")
                        
                        # Push the filter menu screen
                        self.push_screen(FilterMenuScreen(col_key, value), handle_choice)
                    else:
                        self.update_status("Cannot filter on empty value")
                else:
                    self.update_status("Invalid column selection")
            else:
                self.update_status("Invalid row selection")
                
        except Exception as e:
            self.update_status(f"Error: {str(e)}")
            if self.logger:
                self.logger.error(f"Error showing filter menu: {e}", exc_info=True)

    def action_search_filter(self) -> None:
        """Open the searchable value filter modal for the selected column."""
        if not self.last_result or not self.last_result.data:
            self.update_status("No data to search")
            return

        try:
            table = self.query_one("#main-table", DataTable)
        except Exception:
            self.update_status("Table not ready")
            return

        if not table.cursor_coordinate:
            self.update_status("No cell selected")
            return

        cursor_col = table.cursor_coordinate.column
        columns = self.display_columns if self.display_columns else self.last_result.columns
        if cursor_col >= len(columns):
            self.update_status("Invalid column selection")
            return

        col_key = columns[cursor_col]
        group_cols = self.navigation.get_current_group_cols()
        if col_key.lower() not in [gc.lower() for gc in group_cols]:
            self.update_status(f"Cannot search '{col_key}' - only GROUP BY columns can be filtered")
            return

        value_map: Dict[str, Tuple[Any, str, int]] = {}
        for row in self.last_result.data:
            raw_value = row.get(col_key)
            key = repr((type(raw_value).__name__, raw_value))
            if key not in value_map:
                display_value = display_format_value(col_key, raw_value)
                value_map[key] = (raw_value, display_value, 0)
            raw, display, count = value_map[key]
            value_map[key] = (raw, display, count + 1)

        if not value_map:
            self.update_status("No values found for this column")
            return

        entries = [
            ValueEntry(key=k, value=raw, display=display, count=count)
            for k, (raw, display, count) in value_map.items()
        ]
        entries.sort(key=lambda entry: (-entry.count, entry.display.lower()))

        current_frame = self.navigation.current_frame
        include_values = None
        exclude_values = None
        if current_frame:
            for key, value in current_frame.filters.items():
                if key.lower() == col_key.lower():
                    include_values = value
                    break
            for key, value in current_frame.exclude_filters.items():
                if key.lower() == col_key.lower():
                    exclude_values = value
                    break

        def handle_result(result: Optional[Tuple[List[Any], List[Any]]]) -> None:
            if not result:
                self.update_status("Value search cancelled")
                return

            includes, excludes = result
            changed = self.navigation.apply_value_filters(col_key, includes, excludes)
            if not changed:
                self.update_status("No filter changes applied")
                return

            self.refresh_data()
            include_msg = f"{len(includes)} include" if includes else ""
            exclude_msg = f"{len(excludes)} exclude" if excludes else ""
            summary = ", ".join(part for part in [include_msg, exclude_msg] if part)
            self.update_status(f"Applied filters on {col_key} ({summary or 'cleared'})")

        modal = ValueFilterModal(
            column_name=col_key,
            entries=entries,
            initial_include=include_values,
            initial_exclude=exclude_values,
        )

        self.push_screen(modal, handle_result)
    
    def action_drill_down(self) -> None:
        """Handle ENTER key to drill down on current cell"""
        if self.logger:
            self.logger.info("=== action_drill_down called ===")
        
        # Check if we just changed grouping - if so, ignore this Enter key
        if hasattr(self, '_just_changed_grouping') and self._just_changed_grouping:
            self._just_changed_grouping = False
            if self.logger:
                self.logger.info("Ignoring ENTER - just changed grouping")
            return
        
        if not self.last_result:
            if self.logger:
                self.logger.warning("No last_result - returning")
            return
        
        try:
            # Get the current table
            table = self.query_one("#main-table", DataTable)
            if not table.cursor_coordinate:
                if self.logger:
                    self.logger.warning("No cursor coordinate - returning")
                return
            
            cursor_row = table.cursor_coordinate.row
            cursor_col = table.cursor_coordinate.column
            
            if self.logger:
                self.logger.info(f"Cursor position: row={cursor_row}, col={cursor_col}")
            
            # Get the data for the current row
            if cursor_row >= len(self.last_result.data):
                if self.logger:
                    self.logger.warning(f"Cursor row {cursor_row} >= data length {len(self.last_result.data)}")
                return
            
            row_data = self.last_result.data[cursor_row]
            # Use display columns which match the rendered table
            columns = self.display_columns if self.display_columns else self.last_result.columns
            
            if self.logger:
                self.logger.info(f"Display columns: {columns}")
                self.logger.info(f"Row data keys: {list(row_data.keys())[:10]}...")  # First 10 keys
            
            if cursor_col >= len(columns):
                if self.logger:
                    self.logger.warning(f"Cursor col {cursor_col} >= columns length {len(columns)}")
                return
            
            col_key = columns[cursor_col]
            value = row_data.get(col_key)
            
            if self.logger:
                self.logger.info(f"Selected column: {col_key}, value: {value}")
            
            if value is not None and value != '-':
                # Check if this column is a GROUP BY column
                group_cols = self.navigation.get_current_group_cols()
                
                if self.logger:
                    self.logger.info(f"GROUP BY columns: {group_cols}")
                
                # Handle prefixed columns and case-insensitive matching
                is_group_column = False
                filter_column = col_key  # The column to use for filtering
                
                # Standardize to lowercase for comparison
                col_key_lower = col_key.lower()
                group_cols_lower = [gc.lower() for gc in group_cols]
                
                if self.logger:
                    self.logger.info(f"Checking if '{col_key_lower}' is in group columns (lowercase): {group_cols_lower}")
                
                # Direct match (case-insensitive)
                if col_key_lower in group_cols_lower:
                    is_group_column = True
                    # Find the original case version
                    for gc in group_cols:
                        if gc.lower() == col_key_lower:
                            filter_column = gc
                            break
                elif '.' in col_key:
                    # Check if base name is in group cols
                    base_name = col_key.split('.', 1)[1]
                    base_name_lower = base_name.lower()
                    if base_name_lower in group_cols_lower:
                        is_group_column = True
                        # Find the original case version
                        for gc in group_cols:
                            if gc.lower() == base_name_lower:
                                filter_column = gc
                                break
                else:
                    # Check if any prefixed version is in group cols
                    for group_col in group_cols:
                        if '.' in group_col:
                            gc_base = group_col.split('.', 1)[1]
                            if gc_base.lower() == col_key_lower:
                                is_group_column = True
                                filter_column = group_col
                                break
                
                if not is_group_column:
                    msg = f"Cannot drill down on '{col_key}' - only GROUP BY columns can be filtered"
                    self.update_status(msg)
                    if self.logger:
                        self.logger.info(msg)
                    return
                
                # Add filter and drill down
                if self.logger:
                    self.logger.info(f"Column IS a GROUP BY column! Adding filter: {filter_column} = {value}")
                self.navigation.drill_down(filter_column, value)
                self.refresh_data()
                self.update_status(f"Filtered on {filter_column} = {value}")
                if self.logger:
                    self.logger.info(f"Successfully added filter and refreshed data")
                
        except Exception as e:
            if self.logger:
                self.logger.error(f"Error drilling down: {e}", exc_info=True)
            self.update_status("Error drilling down")
    
    def action_back_out(self) -> None:
        """Handle BACKSPACE key to remove last WHERE filter only"""
        if self.navigation.remove_last_filter():
            self.refresh_data()
            self.update_status("Removed last filter")
        else:
            self.update_status("No filters to remove")
    
    # Tab-related actions removed for simplicity
    
    def action_refresh(self) -> None:
        """Refresh current data"""
        self.refresh_data()

    def action_toggle_selection_window(self) -> None:
        """Toggle whether the selection window constrains queries."""

        if not self.selection_enabled:
            self.selection_enabled = True

            if not (self.selection_low and self.selection_high):
                try:
                    sel_low, sel_high = compute_selection_window(
                        self.loaded_low,
                        self.loaded_high,
                    )
                except ValueError as exc:
                    self.selection_enabled = False
                    self.update_status(f"Cannot enable selection window: {exc}")
                    return

                if not sel_low or not sel_high:
                    self.selection_enabled = False
                    self.update_status("Cannot enable selection window: no time range available")
                    return

                self._set_selection_window(sel_low, sel_high, clamp=True)
            else:
                self._update_query_time_range()

            self.refresh_data()
            if self.selection_low and self.selection_high:
                msg = format_time_range_line("Window", self.selection_low, self.selection_high)
                self.update_status(f"Selection window enabled — {msg.split(': ', 1)[1] if ': ' in msg else msg}")
            else:
                self.update_status("Selection window enabled")
            return

        self.selection_enabled = False
        self._update_query_time_range()
        self.refresh_data()
        self.update_status("Selection window disabled (using full range)")

    def action_move_window_left(self) -> None:
        """Shift selection window earlier by one step."""
        if not self.selection_enabled:
            self.update_status("Selection window disabled (press 'w' to enable)")
            return
        if not self._shift_selection_window(-self.window_step):
            self.update_status("Selection window at earliest boundary")
            return
        self.refresh_data()

    def action_move_window_right(self) -> None:
        """Shift selection window later by one step."""
        if not self.selection_enabled:
            self.update_status("Selection window disabled (press 'w' to enable)")
            return
        if not self._shift_selection_window(self.window_step):
            self.update_status("Selection window at latest boundary")
            return
        self.refresh_data()

    def action_undo_grouping(self) -> None:
        """Undo the last grouping change"""
        if self.navigation.undo_last_grouping():
            self.refresh_data()
            self.update_status("Undid last grouping change")
        else:
            self.update_status("No grouping history to undo")
    
    def action_remove_column(self) -> None:
        """Remove current column from the group by list or latency columns"""
        # Get current cursor position
        try:
            table = self.query_one("#main-table", DataTable)
            if not table or not table.cursor_coordinate:
                return
                
            cursor_col_index = table.cursor_coordinate.column
            cursor_row = table.cursor_coordinate.row
            if not self.display_columns or cursor_col_index >= len(self.display_columns):
                return
                
            cursor_column = self.display_columns[cursor_col_index]
            cursor_column_lower = cursor_column.lower()
            
            # Get current group columns
            current_group_cols = self.navigation.get_current_group_cols()
            
            # First check if it's a latency/histogram column
            is_latency_column = False
            matched_latency_column = None
            
            # Check against selected latency columns
            for lat_col in self.selected_latency_columns:
                if lat_col.lower() == cursor_column_lower:
                    is_latency_column = True
                    matched_latency_column = lat_col
                    break
                # Also check visualization columns (e.g., sclat_histogram_viz)
                if cursor_column_lower == f"{lat_col.lower()}_viz":
                    is_latency_column = True
                    matched_latency_column = lat_col
                    break
            
            if is_latency_column:
                # Remove from latency columns
                new_latency_cols = [col for col in self.selected_latency_columns 
                                   if col != matched_latency_column]
                self.selected_latency_columns = new_latency_cols
                self.refresh_data()
                self.update_status(f"Removed latency column: {matched_latency_column}")
                return
            
            # Check if cursor is on a group column (case-insensitive)
            # Need to handle both exact matches and base column names for prefixed columns
            is_group_column = False
            matched_group_column = None
            
            group_cols_lower = {gc.lower(): gc for gc in current_group_cols}
            
            if cursor_column_lower in group_cols_lower:
                # Direct match
                is_group_column = True
                matched_group_column = group_cols_lower[cursor_column_lower]
            else:
                # Check if it's a prefixed column that matches a base group column
                if '.' in cursor_column:
                    base_name = cursor_column.split('.', 1)[1].lower()
                    if base_name in group_cols_lower:
                        is_group_column = True
                        matched_group_column = group_cols_lower[base_name]
                else:
                    # Check if any prefixed group column matches this base name
                    for group_col_lower, group_col_orig in group_cols_lower.items():
                        if '.' in group_col_orig and group_col_orig.split('.', 1)[1].lower() == cursor_column_lower:
                            is_group_column = True
                            matched_group_column = group_col_orig
                            break
            
            # Check if cursor is on a latency/histogram column
            is_latency_column = False
            matched_latency_column = None
            
            # Check various histogram visualization column names
            histogram_viz_columns = ['sclat_histogram_viz', 'iolat_histogram_viz', 'histogram_viz']
            if cursor_column.lower() in histogram_viz_columns:
                # Map visualization column back to its data column
                if cursor_column.lower() == 'sclat_histogram_viz':
                    matched_latency_column = 'sclat_histogram'
                elif cursor_column.lower() == 'iolat_histogram_viz':
                    matched_latency_column = 'iolat_histogram'
                is_latency_column = matched_latency_column in self.selected_latency_columns
            elif cursor_column in self.selected_latency_columns:
                # Direct match with latency column
                is_latency_column = True
                matched_latency_column = cursor_column
            else:
                # Check prefixed latency columns (e.g., sc.avg_lat_us)
                for lat_col in self.selected_latency_columns:
                    if lat_col == cursor_column or (lat_col.startswith('sc.') and cursor_column == lat_col) or \
                       (lat_col.startswith('io.') and cursor_column == lat_col):
                        is_latency_column = True
                        matched_latency_column = lat_col
                        break
            
            if not is_group_column and not is_latency_column:
                self.update_status(f"Column '{cursor_column.lower()}' is not a grouping or latency column")
                return
            
            # Handle group column removal
            if is_group_column:
                # Don't allow removing if it's the only column
                if len(current_group_cols) <= 1:
                    self.update_status("Cannot remove the last grouping column")
                    return
                
                # Find the position of the removed column in the group columns
                removed_col_group_index = current_group_cols.index(matched_group_column)
                
                # Remove the column
                new_group_cols = [col for col in current_group_cols if col != matched_group_column]
                
                # Determine which column to move cursor to after removal
                # If there's a column after the removed one, use that; otherwise use the last column
                if removed_col_group_index < len(new_group_cols):
                    next_column = new_group_cols[removed_col_group_index]
                else:
                    next_column = new_group_cols[-1] if new_group_cols else None
                
                # Update grouping with history (so user can backspace to restore)
                self.navigation.update_grouping(new_group_cols, create_history=True)
                
                # Refresh the display
                self.refresh_data()
                
                # Restore cursor position to the next column
                if next_column:
                    self._restore_cursor_to_column(next_column, cursor_row)
                
                self.update_status(f"Removed '{cursor_column.lower()}' from grouping")
            
            # Handle latency column removal
            elif is_latency_column:
                # Remove from selected latency columns
                self.selected_latency_columns = [col for col in self.selected_latency_columns if col != matched_latency_column]
                
                # Refresh the display
                self.refresh_data()
                
                # Try to restore cursor to a nearby column
                # Find the current column's position in display columns
                try:
                    current_index = self.display_columns.index(cursor_column)
                    # Try to move to the previous column, or next if at start
                    if current_index > 0:
                        next_column = self.display_columns[current_index - 1]
                    elif current_index < len(self.display_columns) - 1:
                        next_column = self.display_columns[current_index + 1]
                    else:
                        next_column = self.display_columns[0] if self.display_columns else None
                    
                    if next_column:
                        self._restore_cursor_to_column(next_column, cursor_row)
                except (ValueError, IndexError):
                    pass
                
                self.update_status(f"Removed '{cursor_column.lower()}' from latency columns")
            
        except Exception as e:
            if self.logger:
                self.logger.error(f"Error removing column: {e}")
            self.update_status("Error removing column")
    
    def action_move_column_left(self) -> None:
        """Move current column left in the grouping"""
        self._move_column(-1)
    
    def action_move_column_right(self) -> None:
        """Move current column right in the grouping"""
        self._move_column(1)
    
    def _restore_cursor_to_column(self, column_name: str, row: int = 0) -> None:
        """Restore cursor to a specific column after refresh"""
        try:
            table = self.query_one("#main-table", DataTable)
            if not table or not self.display_columns:
                return
                
            # Find the column index in the display columns
            col_index = -1
            
            # Direct match
            if column_name in self.display_columns:
                col_index = self.display_columns.index(column_name)
            else:
                # Try to match by base name (for prefixed columns)
                if '.' in column_name:
                    base_name = column_name.split('.', 1)[1]
                    for i, display_col in enumerate(self.display_columns):
                        if display_col == base_name or (
                            '.' in display_col and display_col.split('.', 1)[1] == base_name
                        ):
                            col_index = i
                            break
                else:
                    # Look for a prefixed version
                    for i, display_col in enumerate(self.display_columns):
                        if display_col == column_name or (
                            '.' in display_col and display_col.split('.', 1)[1] == column_name
                        ):
                            col_index = i
                            break
            
            if col_index >= 0:
                # Move cursor to the column, keeping the same row if possible
                table.move_cursor(row=min(row, table.row_count - 1), column=col_index)
        except Exception as e:
            if self.logger:
                self.logger.debug(f"Could not restore cursor position: {e}")
    
    def _move_column(self, direction: int) -> None:
        """Move column in the specified direction (-1 for left, 1 for right)"""
        # Get current cursor position
        try:
            table = self.query_one("#main-table", DataTable)
            if not table or not table.cursor_coordinate:
                return
                
            cursor_col_index = table.cursor_coordinate.column
            if not self.display_columns or cursor_col_index >= len(self.display_columns):
                return
                
            cursor_column = self.display_columns[cursor_col_index]
            cursor_column_lower = cursor_column.lower()
            
            # First check if it's a latency/histogram column
            is_latency_column = False
            matched_latency_column = None
            
            # Check against selected latency columns
            for lat_col in self.selected_latency_columns:
                if lat_col.lower() == cursor_column_lower:
                    is_latency_column = True
                    matched_latency_column = lat_col
                    break
                # Also check visualization columns
                if cursor_column_lower == f"{lat_col.lower()}_viz":
                    is_latency_column = True
                    matched_latency_column = lat_col
                    break
                # Check for formatted names (e.g., sc_p99_us for sc.p99_us)
                formatted_name = lat_col.replace('.', '_').lower()
                if cursor_column_lower == formatted_name:
                    is_latency_column = True
                    matched_latency_column = lat_col
                    break
            
            if is_latency_column:
                # Move latency column
                old_index = self.selected_latency_columns.index(matched_latency_column)
                new_index = old_index + direction
                
                # Check bounds
                if new_index < 0 or new_index >= len(self.selected_latency_columns):
                    self.update_status(f"Cannot move latency column {'left' if direction < 0 else 'right'}")
                    return
                
                # Reorder the latency columns
                new_latency_cols = self.selected_latency_columns.copy()
                new_latency_cols[old_index], new_latency_cols[new_index] = new_latency_cols[new_index], new_latency_cols[old_index]
                self.selected_latency_columns = new_latency_cols
                
                # Refresh the display
                self.refresh_data()
                
                # Try to keep cursor on the same column after refresh
                self.call_after_refresh(lambda: self._restore_cursor_position(cursor_column))
                
                self.update_status(f"Moved latency column '{matched_latency_column}' {'left' if direction < 0 else 'right'}")
                return
            
            # Get current group columns
            current_group_cols = self.navigation.get_current_group_cols()
            
            # Check if cursor is on a group column (handle prefixed columns, case-insensitive)
            is_group_column = False
            matched_group_column = None
            
            group_cols_lower = {gc.lower(): gc for gc in current_group_cols}
            
            if cursor_column_lower in group_cols_lower:
                is_group_column = True
                matched_group_column = group_cols_lower[cursor_column_lower]
            else:
                # Handle prefixed column matching
                if '.' in cursor_column:
                    base_name = cursor_column.split('.', 1)[1].lower()
                    if base_name in group_cols_lower:
                        is_group_column = True
                        matched_group_column = group_cols_lower[base_name]
                else:
                    for group_col_lower, group_col_orig in group_cols_lower.items():
                        if '.' in group_col_orig and group_col_orig.split('.', 1)[1].lower() == cursor_column_lower:
                            is_group_column = True
                            matched_group_column = group_col_orig
                            break
            
            if not is_group_column:
                self.update_status(f"Column '{cursor_column.lower()}' is not a movable column")
                return
            
            # Find position in group columns
            old_index = current_group_cols.index(matched_group_column)
            new_index = old_index + direction
            
            # Check bounds
            if new_index < 0 or new_index >= len(current_group_cols):
                self.update_status(f"Cannot move column {'left' if direction < 0 else 'right'}")
                return
            
            # Reorder the columns
            new_group_cols = current_group_cols.copy()
            new_group_cols[old_index], new_group_cols[new_index] = new_group_cols[new_index], new_group_cols[old_index]
            
            # Update grouping without creating history (this is just a reorder)
            self.navigation.update_grouping(new_group_cols, create_history=False)
            
            # Refresh the display
            self.refresh_data()
            
            # Try to keep cursor on the same column after refresh
            self.call_after_refresh(lambda: self._restore_cursor_position(cursor_column))
            
            self.update_status(f"Moved '{cursor_column.lower()}' {'left' if direction < 0 else 'right'}")
            
        except Exception as e:
            if self.logger:
                self.logger.error(f"Error moving column: {e}")
            self.update_status("Error moving column")
    
    def _restore_cursor_position(self, column_name: str) -> None:
        """Restore cursor to specific column after refresh"""
        if not self.display_columns:
            return
            
        try:
            table = self.query_one("#main-table", DataTable)
            if table and column_name in self.display_columns:
                new_col_index = self.display_columns.index(column_name)
                # Keep same row, move to new column position
                if table.cursor_coordinate:
                    table.move_cursor(column=new_col_index)
        except Exception:
            pass
    
    def action_toggle_help(self) -> None:
        """Toggle the Textual keys / help panel."""
        try:
            self.screen.query_one(HelpPanel)
        except NoMatches:
            self.action_show_help_panel()
            self.update_status("Showing keys/help panel")
        else:
            self.action_hide_help_panel()
            self.update_status("Closed keys/help panel")
    
    def action_peek_cell(self) -> None:
        """Show detailed information about the current cell"""
        if self.logger:
            self.logger.info("action_peek_cell called")
        
        try:
            table = self.query_one("#main-table", DataTable)
        except Exception as e:
            if self.logger:
                self.logger.error(f"Failed to get table: {e}", exc_info=True)
            self.update_status("Failed to get table")
            return
        if not table.cursor_coordinate:
            if self.logger:
                self.logger.debug("No cursor coordinate")
            return
        
        row_key, column_key = table.cursor_coordinate
        if self.logger:
            self.logger.debug(f"Cursor at row_key={row_key}, column_key={column_key}")
        
        # Get the column information
        try:
            # cursor_coordinate returns indices, need to get actual keys
            column_keys = list(table.columns.keys())
            row_keys = list(table.rows.keys())
            
            if self.logger:
                self.logger.debug(f"Column index: {column_key}, total columns: {len(column_keys)}")
            
            # Get the actual keys from indices
            if column_key < len(column_keys) and row_key < len(row_keys):
                actual_column_key = column_keys[column_key]
                actual_row_key = row_keys[row_key]
                
                column = table.columns[actual_column_key]
                column_name = column.label.plain
                
                if self.logger:
                    self.logger.debug(f"Column found: {column_name}")
            else:
                if self.logger:
                    self.logger.error(f"Indices out of range: row={row_key}, col={column_key}")
                return
        except Exception as e:
            if self.logger:
                self.logger.error(f"Unexpected error getting column: {e}", exc_info=True)
            return
        
        # Get the cell value
        try:
            # Use the actual keys
            cell_value = table.get_cell(actual_row_key, actual_column_key)
            if self.logger:
                self.logger.debug(f"Cell value retrieved: {cell_value[:20] if isinstance(cell_value, str) else cell_value}")
        except Exception as e:
            if self.logger:
                self.logger.error(f"Failed to get cell value: {e}")
            return
        
        # Get the full row data
        # IMPORTANT: row_key from cursor_coordinate is just the visual row index,
        # but the table might have internal row keys. We need to map properly.
        row_data = None
        if self.logger:
            self.logger.debug("Attempting to get row data")
        
        if hasattr(self, 'current_data') and self.current_data:
            try:
                # Get the actual row index from table's row keys
                # The row_key from cursor_coordinate is the index into row_keys list
                row_keys = list(table.rows.keys())
                if row_key < len(row_keys):
                    # Since we add rows without explicit keys, they get auto-generated keys
                    # The order should match self.current_data order
                    # Use row_key directly as index into current_data
                    if row_key < len(self.current_data):
                        row_data = self.current_data[row_key]
                        if self.logger:
                            self.logger.debug(f"Row data retrieved at index {row_key}, keys: {list(row_data.keys()) if row_data else 'None'}")
                            # Log actual values for debugging
                            if row_data and 'state' in row_data:
                                self.logger.debug(f"Row state value: {row_data['state']}")
            except (ValueError, IndexError) as e:
                if self.logger:
                    self.logger.error(f"Failed to get row data: {e}")
        else:
            if self.logger:
                self.logger.debug("No current_data available")
        
        # Log column information for debugging
        if self.logger:
            self.logger.debug(f"Peek cell - column_name: '{column_name}', column_key: {column_key}")
            if row_data:
                self.logger.debug(f"Row data keys: {list(row_data.keys())}")
        
        # Special handling for histogram columns
        # Check both the column name and the display header
        # We know from logs the histogram column shows as "<1us__32us_1ms__32ms_1s_8∞"
        if self.logger:
            self.logger.debug(f"Checking if histogram column: '{column_name}'")
        
        if (column_name.strip() == '<1us__32us_1ms__32ms_1s_8∞' or 
            column_name.lower() == 'histogram_viz' or
            column_name.lower() in ['sclat_histogram_viz', 'iolat_histogram_viz']):
            # Find the corresponding histogram data and determine the actual column type
            hist_data = None
            actual_histogram_column = None
            
            if row_data:
                # Check for histogram data in row (always dynamic query now)
                if 'sclat_histogram' in row_data:
                    hist_data = row_data['sclat_histogram']
                    actual_histogram_column = 'sclat_histogram'
                elif 'iolat_histogram' in row_data:
                    hist_data = row_data['iolat_histogram']
                    actual_histogram_column = 'iolat_histogram'
                
                # Also check based on the column name itself
                if column_name.lower() == 'sclat_histogram_viz':
                    actual_histogram_column = 'sclat_histogram'
                elif column_name.lower() == 'iolat_histogram_viz':
                    actual_histogram_column = 'iolat_histogram'
                
                if self.logger:
                    self.logger.debug(f"Found histogram data: {bool(hist_data)}, actual column: {actual_histogram_column}")
            
            # Instead of using the pre-aggregated histogram, we'll run a query
            try:
                if self.logger:
                    self.logger.debug(f"Creating histogram peek modal with query context")
                
                # Build filter conditions ONLY from the current row's GROUP BY columns
                # Do NOT include navigation filters - we want to see the histogram for this specific row
                filter_conditions = []
                current_group_cols = self.navigation.get_current_group_cols()
                
                if self.logger:
                    self.logger.debug(f"Current GROUP BY columns: {current_group_cols}")
                    self.logger.debug(f"Row data keys: {list(row_data.keys())}")
                
                for key in current_group_cols:
                    # Handle case-insensitive matching
                    key_lower = key.lower()
                    
                    # Find matching column in row_data (case-insensitive)
                    value = None
                    for row_key, row_value in row_data.items():
                        if row_key.lower() == key_lower:
                            value = row_value
                            break
                    
                    if value is not None and str(value) != '-':
                        # Escape single quotes in values
                        escaped_value = str(value).replace("'", "''")
                        filter_conditions.append(f"{key} = '{escaped_value}'")
                        if self.logger:
                            self.logger.debug(f"Added filter: {key} = '{escaped_value}'")
                
                where_clause = " AND ".join(filter_conditions) if filter_conditions else "1=1"
                
                if self.logger:
                    self.logger.debug(f"Histogram peek where clause: {where_clause}")
                
                # Create modal with query context
                # Use the actual histogram column name (sclat_histogram or iolat_histogram) 
                # instead of the display header
                modal = HistogramPeekModal(
                    column_name=actual_histogram_column or column_name,
                    query_type='dynamic',  # Always dynamic now
                    datadir=self.datadir,
                    where_clause=where_clause,
                    low_time=self.query_params.low_time,
                    high_time=self.query_params.high_time,
                    engine=self.query_engine,
                    histogram_data=hist_data
                )
                
                if self.logger:
                    self.logger.debug("Modal created, pushing screen")
                self.push_screen(modal)
                if self.logger:
                    self.logger.debug("Modal pushed successfully")
            except Exception as e:
                if self.logger:
                    self.logger.error(f"Failed to create/push modal: {e}", exc_info=True)
                self.update_status(f"Error showing histogram: {str(e)}")
        # Check for percentile columns (e.g., sc.p95_us, io.p99_us, sc_p95_us, io_p99_us)
        elif ('p50' in column_name.lower() or 'p95' in column_name.lower() or 
              'p99' in column_name.lower() or 'p999' in column_name.lower()):
            # Determine if it's syscall or I/O latency based on column prefix
            is_syscall = column_name.lower().startswith('sc') or 'sysc' in column_name.lower()
            
            if self.logger:
                self.logger.debug(f"Percentile column detected: {column_name}, is_syscall: {is_syscall}")
            
            # Build filter conditions ONLY from the current row's GROUP BY columns
            # Do NOT include navigation filters - we want to see the histogram for this specific row
            filter_conditions = []
            current_group_cols = self.navigation.get_current_group_cols()
            
            if self.logger:
                self.logger.debug(f"Percentile peek - Current GROUP BY columns: {current_group_cols}")
                self.logger.debug(f"Percentile peek - Row data keys: {list(row_data.keys())}")
            
            for key in current_group_cols:
                # Handle case-insensitive matching
                key_lower = key.lower()
                
                # Find matching column in row_data (case-insensitive)
                value = None
                for row_key, row_value in row_data.items():
                    if row_key.lower() == key_lower:
                        value = row_value
                        break
                
                if value is not None and str(value) != '-':
                    # Escape single quotes in values
                    escaped_value = str(value).replace("'", "''")
                    filter_conditions.append(f"{key} = '{escaped_value}'")
                    if self.logger:
                        self.logger.debug(f"Percentile peek - Added filter: {key} = '{escaped_value}'")
            
            where_clause = " AND ".join(filter_conditions) if filter_conditions else "1=1"
            
            # Determine which histogram type to show
            histogram_column = 'sclat_histogram' if is_syscall else 'iolat_histogram'
            
            # Get histogram data if available
            hist_data = None
            if row_data:
                if histogram_column in row_data:
                    hist_data = row_data[histogram_column]
            
            try:
                # Create histogram modal for percentile drill-down
                modal = HistogramPeekModal(
                    column_name=histogram_column,
                    query_type='dynamic',
                    datadir=self.datadir,
                    where_clause=where_clause,
                    low_time=self.query_params.low_time,
                    high_time=self.query_params.high_time,
                    engine=self.query_engine,
                    histogram_data=hist_data
                )
                
                if self.logger:
                    self.logger.debug(f"Created histogram modal for percentile column {column_name}")
                self.push_screen(modal)
            except Exception as e:
                if self.logger:
                    self.logger.error(f"Failed to create percentile peek modal: {e}", exc_info=True)
                self.update_status(f"Error showing percentile details: {str(e)}")
        # Check for stack hash columns
        elif column_name.lower() in ['kstack_hash', 'ustack_hash']:
            # Determine if it's kernel or user stack
            is_kernel = column_name.lower() == 'kstack_hash'
            
            # Look up the stack trace
            if cell_value and cell_value != '0' and cell_value != '-':
                try:
                    stack_trace = self.query_engine.lookup_stack_trace(cell_value, is_kernel)
                    
                    if self.logger:
                        self.logger.debug(f"Looking up {'kernel' if is_kernel else 'user'} stack hash: {cell_value}")
                        self.logger.debug(f"Found stack trace: {bool(stack_trace)}")
                    
                    # Show the stack peek modal
                    modal = StackPeekModal(
                        stack_hash=cell_value,
                        is_kernel=is_kernel,
                        stack_trace=stack_trace
                    )
                    self.push_screen(modal)
                except Exception as e:
                    if self.logger:
                        self.logger.error(f"Failed to lookup stack trace: {e}", exc_info=True)
                    self.update_status(f"Error looking up stack trace: {str(e)}")
            else:
                self.update_status("No stack trace available (empty or zero hash)")
        # Check for extra_info column (JSON data)
        elif column_name.lower() == 'extra_info':
            if cell_value and cell_value != '-':
                try:
                    if self.logger:
                        self.logger.debug(f"Opening JSON viewer for extra_info: {cell_value[:50]}...")
                    
                    # Show the JSON viewer modal
                    modal = JSONViewerModal(
                        column_name=column_name,
                        json_data=cell_value,
                        row_context=row_data
                    )
                    self.push_screen(modal)
                except Exception as e:
                    if self.logger:
                        self.logger.error(f"Failed to create JSON viewer modal: {e}", exc_info=True)
                    self.update_status(f"Error showing JSON data: {str(e)}")
            else:
                self.update_status("No JSON data available (empty or dash)")
        else:
            self.update_status(f"Cell peek not implemented for column: {column_name}")
    
    # Removed action_change_query - no longer needed with only dynamic queries
    
    def action_change_grouping(self) -> None:
        """Change grouping columns"""
        # Get current cursor position to determine where to insert new columns
        cursor_column = None
        try:
            table = self.query_one("#main-table", DataTable)
            if table and table.cursor_coordinate:
                cursor_col_index = table.cursor_coordinate.column
                if self.display_columns and cursor_col_index < len(self.display_columns):
                    cursor_column = self.display_columns[cursor_col_index]
                    if self.logger:
                        self.logger.info(f"Cursor is on column: {cursor_column} (index {cursor_col_index})")
        except Exception:
            pass
        
        # Get available columns dynamically from query engine
        # Always use dynamic column discovery now
        columns_by_source = self.query_engine.get_columns_by_source()
        
        # Get current grouping columns
        current_columns = self.navigation.get_current_group_cols()
        
        def handle_grouping_choice(new_columns: Optional[List[str]]) -> None:
            """Handle grouping menu result"""
            # Compare columns case-insensitively
            if new_columns and [col.lower() for col in new_columns] != [col.lower() for col in current_columns]:
                # Store current cursor info before refresh
                saved_cursor_row = None
                saved_cursor_column = None
                try:
                    table = self.query_one("#main-table", DataTable)
                    if table and table.cursor_coordinate:
                        saved_cursor_row = table.cursor_coordinate.row
                        saved_cursor_column = self.display_columns[table.cursor_coordinate.column] if self.display_columns and table.cursor_coordinate.column < len(self.display_columns) else None
                except Exception:
                    pass
                # Smart insertion: if we're adding columns and cursor was on a column,
                # insert new columns at the cursor position (before it)
                if cursor_column and len(new_columns) > len(current_columns):
                    # Find which columns were added (case-insensitive comparison)
                    current_columns_lower = [col.lower() for col in current_columns]
                    added_columns = [col for col in new_columns if col.lower() not in current_columns_lower]
                    
                    if added_columns:
                        # Remove added columns from their default positions
                        reordered_columns = [col for col in new_columns if col not in added_columns]
                        
                        # Find insertion index: at the cursor column position
                        if cursor_column in reordered_columns:
                            insert_index = reordered_columns.index(cursor_column)
                        else:
                            # If the cursor column was deselected, try to use its old index
                            try:
                                old_idx = current_columns.index(cursor_column)
                            except ValueError:
                                old_idx = len(reordered_columns)
                            # Clamp to current bounds
                            insert_index = min(max(old_idx, 0), len(reordered_columns))
                        
                        # Insert the new columns at computed index, preserving user-selected order
                        for col in added_columns:
                            reordered_columns.insert(insert_index, col)
                            insert_index += 1
                        
                        new_columns = reordered_columns
                        
                        if self.logger:
                            self.logger.info(f"Reordered columns for smart insertion at cursor: {new_columns}")
                # Get current filters before changing grouping
                current_filters = self.navigation.get_current_filters()
                current_exclude_filters = self.navigation.current_frame.exclude_filters.copy() if self.navigation.current_frame else {}
                
                # Identify aggregate columns that should be removed from filters
                # These are columns that are computed by GROUP BY, not raw data columns
                aggregate_columns = {'total_samples', 'avg_threads', 'est_sc_cnt', 
                                   'min_lat_us', 'avg_lat_us', 'max_lat_us',
                                   'p50_us', 'p95_us', 'p99_us', 'p999_us',
                                   'est_time_s', 'sclat_histogram', 'iolat_histogram'}
                
                # Update navigation with new grouping FIRST (this creates the history)
                self.navigation.update_grouping(new_columns, create_history=True)
                
                # THEN remove any filters on aggregate columns (both include and exclude)
                # This ensures the filters are removed from the current frame, not the historical one
                all_filter_columns = list(current_filters.keys()) + list(current_exclude_filters.keys())
                for col in all_filter_columns:
                    if col in aggregate_columns:
                        self.navigation.remove_filter(col)
                        if self.logger:
                            self.logger.info(f"Removed filter on aggregate column: {col}")
                
                # Set flag to prevent immediate drill-down
                self._just_changed_grouping = True
                
                # Refresh the display
                self.refresh_data()
                
                # Restore cursor position
                if saved_cursor_column:
                    # If the saved column was removed, find the next appropriate column
                    if saved_cursor_column not in new_columns:
                        # Find where the removed column would have been
                        old_col_index = current_columns.index(saved_cursor_column) if saved_cursor_column in current_columns else -1
                        if old_col_index >= 0:
                            # Find the next column that still exists
                            for i in range(old_col_index, len(current_columns)):
                                if current_columns[i] in new_columns:
                                    saved_cursor_column = current_columns[i]
                                    break
                            else:
                                # If no column found after, try before
                                for i in range(old_col_index - 1, -1, -1):
                                    if current_columns[i] in new_columns:
                                        saved_cursor_column = current_columns[i]
                                        break
                                else:
                                    # Fall back to last column in new columns
                                    saved_cursor_column = new_columns[-1] if new_columns else None
                    
                    # Restore to the column
                    if saved_cursor_column:
                        self._restore_cursor_to_column(saved_cursor_column, saved_cursor_row or 0)
                
                # Show status with warning if filters were removed
                removed_filters = []
                for col in all_filter_columns:
                    if col in aggregate_columns and col not in removed_filters:
                        removed_filters.append(col)
                
                if removed_filters:
                    self.update_status(f"Grouped by: {', '.join(new_columns)} (removed filters on: {', '.join(removed_filters)})")
                else:
                    self.update_status(f"Grouped by: {', '.join(new_columns)}")
            elif new_columns is None:
                self.update_status("Grouping cancelled")
        
        # Show the grouping menu with the cursor column for auto-selection
        self.push_screen(
            GroupingMenuScreen(columns_by_source, current_columns,
                             query_engine=self.query_engine, initial_highlight=cursor_column),
            handle_grouping_choice
        )
    
    def action_latency_columns(self) -> None:
        """Show latency/aggregate columns selector"""
        # Define available latency columns based on data sources
        available_latency_columns = {
            "System Call Latency": [],
            "I/O Latency": [],
            "Histograms": []
        }
        
        # Check which data sources are in use
        current_group_cols = self.navigation.get_current_group_cols()
        columns_in_use = set()
        
        # Check if syscend columns are available
        for col in current_group_cols:
            if col.startswith('sc.') or col in ['syscall', 'syscall_name']:
                columns_in_use.add('syscend')
            elif col.startswith('io.') or col == 'devname':
                columns_in_use.add('iorqend')
        
        # Add syscall latency columns (always available with dynamic queries)
        if True:  # Always available with dynamic queries
            available_latency_columns["System Call Latency"] = [
                'sc.min_lat_us',
                'sc.avg_lat_us', 
                'sc.max_lat_us',
                'sc.p50_us',
                'sc.p95_us',
                'sc.p99_us',
                'sc.p999_us'
            ]
            available_latency_columns["Histograms"].append('sclat_histogram')
        
        # Add I/O latency columns (always available with dynamic queries)
        if True:  # Always available with dynamic queries
            available_latency_columns["I/O Latency"] = [
                'io.min_lat_us',
                'io.avg_lat_us',
                'io.max_lat_us',
                'io.p50_us',
                'io.p95_us',
                'io.p99_us',
                'io.p999_us'
            ]
            available_latency_columns["Histograms"].append('iolat_histogram')
        
        # If no specific data source is detected, show all options
        if not any(available_latency_columns.values()):
            available_latency_columns = {
                "System Call Latency": [
                    'sc.min_lat_us',
                    'sc.avg_lat_us', 
                    'sc.max_lat_us',
                    'sc.p50_us',
                    'sc.p95_us',
                    'sc.p99_us',
                    'sc.p999_us'
                ],
                "I/O Latency": [
                    'io.min_lat_us',
                    'io.avg_lat_us',
                    'io.max_lat_us',
                    'io.p50_us',
                    'io.p95_us',
                    'io.p99_us',
                    'io.p999_us'
                ],
                "Histograms": ['sclat_histogram', 'iolat_histogram']
            }
        
        def handle_latency_choice(selected_columns: Optional[List[str]]) -> None:
            """Handle latency columns selection"""
            if selected_columns is not None:
                self.selected_latency_columns = selected_columns
                # Refresh to show the new columns
                self.refresh_data()
                if selected_columns:
                    self.update_status(f"Selected {len(selected_columns)} latency columns")
                else:
                    self.update_status("Cleared latency columns")
        
        # Show the latency columns menu
        self.push_screen(
            LatencyColumnsScreen(available_latency_columns, self.selected_latency_columns,
                               query_engine=self.query_engine),
            handle_latency_choice
        )
    
    @on(DataTable.CellSelected)
    def on_cell_selected(self, event: DataTable.CellSelected) -> None:
        """Handle cell selection for drill-down"""
        if not self.last_result:
            return
        
        # Get selected cell info
        table = event.data_table
        if event.coordinate.row < len(self.last_result.data):
            row_data = self.last_result.data[event.coordinate.row]
            # Use display columns which match the rendered table
            columns = self.display_columns if self.display_columns else self.last_result.columns
            
            if event.coordinate.column < len(columns):
                col_key = columns[event.coordinate.column]
                value = row_data.get(col_key)
                
                # Check if this is a filterable column
                group_cols = self.navigation.get_current_group_cols()
                
                # Handle prefixed columns
                is_filterable = False
                if col_key in group_cols:
                    is_filterable = True
                elif '.' in col_key:
                    # Check base name
                    base_name = col_key.split('.', 1)[1]
                    is_filterable = base_name in group_cols
                else:
                    # Check if any prefixed version is in group cols
                    for group_col in group_cols:
                        if '.' in group_col and group_col.split('.', 1)[1] == col_key:
                            is_filterable = True
                            break
                
                if is_filterable:
                    self.update_status(f"Selected: {col_key}={value} (Press Enter to filter)")
                else:
                    self.update_status(f"Selected: {col_key}={value} (aggregate column - not filterable)")
    
    def on_data_table_cell_selected(self, event: DataTable.CellSelected) -> None:
        """Handle DataTable cell selection (ENTER key on cell)"""
        if self.logger:
            self.logger.info(f"=== DataTable.CellSelected event: row={event.coordinate.row}, col={event.coordinate.column} ===")
        
        # Call our drill down action
        self.action_drill_down()
        
        # Prevent default handling
        event.stop()
    
    def on_key(self, event: events.Key) -> None:
        """Handle key press events"""
        if self.logger:
            self.logger.debug(f"on_key called with key: {event.key}")
        
        # Don't process keys if a modal screen is active
        # In Textual, when a modal is showing, the app's screen_stack will have more than 1 screen
        if hasattr(self, 'screen_stack') and len(self.screen_stack) > 1:
            if self.logger:
                self.logger.debug(f"Modal active, ignoring key: {event.key}")
            return
            
        # Clear the just_changed_grouping flag on any key press except Enter
        if event.key != "enter" and self._just_changed_grouping:
            self._just_changed_grouping = False
            if self.logger:
                self.logger.debug("Cleared _just_changed_grouping flag")
        
        if event.key == "enter":
            if self.logger:
                self.logger.info("ENTER key detected in on_key")
            # Check if we just changed grouping - if so, ignore this Enter key
            if self._just_changed_grouping:
                self._just_changed_grouping = False
                if self.logger:
                    self.logger.info("Ignoring ENTER in on_key - just changed grouping")
                return
            # Let the binding handle the ENTER key - don't process it here
            if self.logger:
                self.logger.info("on_key: Letting binding handle ENTER key")


def parse_args():
    """Parse command-line arguments"""
    parser = argparse.ArgumentParser(
        description='xtop: Top for wall-clock time by Tanel Poder [tanelpoder.com]',
        formatter_class=argparse.RawDescriptionHelpFormatter
    )
    
    # Use XCAPTURE_DATADIR environment variable as default, or None if not set
    default_datadir = os.environ.get('XCAPTURE_DATADIR', '/var/lib/xcapture')
    
    parser.add_argument('-d', '--datadir', default=default_datadir,
                        help='Directory containing xcapture CSV files (default: $XCAPTURE_DATADIR or required if not set)')
    
    # Query type is always 'dynamic' now - removed -q option
    
    parser.add_argument('--from', dest='from_time', type=str, metavar='TIME',
                        help='Start time (ISO format: YYYY-MM-DD HH:MM:SS or HH:MM:SS for today)')

    parser.add_argument('--to', dest='to_time', type=str, metavar='TIME',
                        help='End time (ISO format: YYYY-MM-DD HH:MM:SS or HH:MM:SS for today)')

    parser.add_argument('--window-from', dest='window_from', type=str, metavar='TIME',
                        help='Selection window start (defaults to last 5 minutes of loaded range)')

    parser.add_argument('--window-to', dest='window_to', type=str, metavar='TIME',
                        help='Selection window end (defaults to loaded range end)')

    parser.add_argument('-W', '--window', dest='window_enabled', action='store_true',
                        help='Enable the selection window at startup')
    
    parser.add_argument('-g', '--group', dest='group_by', type=str, metavar='COLS',
                        help='Initial GROUP BY columns (comma-separated, case-insensitive). '
                             'Replaces default columns. Example: -g "state,user,exe"')
    
    parser.add_argument('-G', '--append-group', dest='append_group_by', type=str, metavar='COLS',
                        help='Append to default GROUP BY columns (comma-separated, case-insensitive). '
                             'Example: -G "pid,tid" adds PID and TID to default columns')
    
    parser.add_argument('--debuglog', dest='debug_log', type=str, metavar='FILE',
                        help='Enable debug logging to specified file')
    
    parser.add_argument('--duckdb-threads', type=int, default=None, metavar='N',
                        help='Number of DuckDB threads (1 for deterministic results, default: auto)')
    parser.add_argument('--duckdb-profiling', type=str, default='standard',
                        help='DuckDB profiling mode when --debuglog is enabled (standard, query_tree, json). Default: standard')
    
    # Text mode (non-interactive) and output options
    parser.add_argument('--textmode', '--testmode', dest='testmode', action='store_true',
                        help='Run in non-interactive (text) mode and print results to stdout')
    parser.add_argument('--format', dest='out_format', choices=['table', 'csv', 'json'], default='table',
                        help='Output format in text mode (default: table)')
    parser.add_argument('--no-reorder', action='store_true',
                        help='Do not reorder columns in table output (deterministic order)')
    parser.add_argument('--peek', nargs='?', const=0, type=int,
                        help='Peek row index (0-based). If provided without a value, uses 0 (first row).')
    
    # Additional query controls useful in text mode
    parser.add_argument('-L', '--latency', dest='latency_cols', type=str, metavar='COLS',
                        help='Latency/percentile/histogram columns (comma-separated)')
    parser.add_argument('-l', '--list', dest='list_columns', action='store_true',
                        help='List all available columns for grouping (including derived) and exit')
    parser.add_argument('-w', '--where', dest='where_clause', type=str, default='1=1',
                        help='SQL WHERE clause filter for dynamic queries')
    parser.add_argument('--limit', dest='limit', type=int, default=30,
                        help='Row limit for results (default: 30)')
    
    parser.add_argument('--version', action='version',
                        version=f'%(prog)s {__version__}')
    
    return parser.parse_args()


def main():
    """Main entry point"""
    args = parse_args()
    
    # If listing columns, force textmode and don't execute queries later
    if getattr(args, 'list_columns', False):
        setattr(args, 'testmode', True)
    
    # Configure debug logging for textmode as well if --debuglog is provided
    if getattr(args, 'testmode', False) and getattr(args, 'debug_log', None):
        logging.basicConfig(
            filename=args.debug_log,
            level=logging.DEBUG,
            format='%(asctime)s - %(levelname)s - %(message)s'
        )
    
    # Check if datadir is provided (either via command line or environment variable)
    if not args.datadir:
        print("Error: Data directory not specified.", file=sys.stderr)
        print("Please either:", file=sys.stderr)
        print("  1. Set the XCAPTURE_DATADIR environment variable", file=sys.stderr)
        print("  2. Use the -d/--datadir command line option", file=sys.stderr)
        sys.exit(1)
    
    try:
        now = datetime.now().replace(microsecond=0)
        low_time, high_time, _time_meta = resolve_time_range(
            args.from_time,
            args.to_time,
            now=now,
        )
        
        # If no time range provided, auto-select the latest 60 minutes of available data
        if not low_time and not high_time:
            def _detect_default_time_range(datadir: str):
                from pathlib import Path as _Path
                import re as _re
                candidates = []
                dd = _Path(datadir)
                for ext in ("parquet", "csv"):
                    for p in dd.glob(f"xcapture_samples_*.{ext}"):
                        m = _re.search(r"xcapture_samples_(\d{4}-\d{2}-\d{2})\.(\d{2})\.", p.name + '.')
                        if m:
                            date_str, hh = m.group(1), m.group(2)
                            try:
                                return datetime.strptime(f"{date_str} {hh}", "%Y-%m-%d %H")
                            except Exception:
                                continue
                return None
            # Find the latest hour by scanning, then set low/high around it
            latest_hour = None
            from pathlib import Path as _Path
            import re as _re
            candidates = []
            for ext in ("parquet", "csv"):
                for p in _Path(args.datadir).glob(f"xcapture_samples_*.{ext}"):
                    m = _re.search(r"xcapture_samples_(\d{4}-\d{2}-\d{2})\.(\d{2})\.", p.name + '.')
                    if m:
                        try:
                            candidates.append(datetime.strptime(f"{m.group(1)} {m.group(2)}", "%Y-%m-%d %H"))
                        except Exception:
                            pass
            if candidates:
                latest_hour = max(candidates)
                high_time = latest_hour.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
                low_time = high_time - timedelta(hours=1)

        window_override_low = None
        window_override_high = None
        if args.window_from or args.window_to:
            window_override_low, window_override_high, _window_meta = resolve_time_range(
                args.window_from,
                args.window_to,
                now=high_time or now,
            )

        try:
            selection_low_init, selection_high_init = compute_selection_window(
                low_time,
                high_time,
                window_override_low,
                window_override_high,
            )
        except ValueError as exc:
            print(f"Error: {exc}", file=sys.stderr)
            sys.exit(1)

        selection_enabled = bool(
            getattr(args, 'window_enabled', False) or args.window_from or args.window_to
        )

        # Parse group by columns if provided (normalize to lowercase)
        initial_group_by = None
        if args.group_by:
            # Split by comma, strip whitespace, convert to lowercase
            initial_group_by = [col.strip().lower() for col in args.group_by.split(',') if col.strip()]
        
        # Parse append group by columns if provided (normalize to lowercase)
        append_group_by = None
        if args.append_group_by:
            # Split by comma, strip whitespace, convert to lowercase
            append_group_by = [col.strip().lower() for col in args.append_group_by.split(',') if col.strip()]
        
        # If --testmode is specified, run non-interactive path
        if args.testmode:
            # Initialize data source and engine once
            data_source = XCaptureDataSource(args.datadir, duckdb_threads=args.duckdb_threads)
            engine = QueryEngine(data_source, duckdb_profiling_mode=args.duckdb_profiling)
            
            # Column listing mode (formatted, with types)
            if args.list_columns:
                schema_by_src = engine._discover_all_schemas() or None
                # Use existing cache via helper
                # Build (name,type) dict
                schema = {}
                for src, cols in engine.schema_cache.items():
                    schema[src] = list(cols)
                # Append derived to samples as VARCHAR
                try:
                    from core.query_builder import QueryBuilder as _QB
                    samples_list = schema.get('samples', [])
                    lower = {n.lower() for n, _ in samples_list}
                    for name in _QB.COMPUTED_COLUMNS:
                        if name.lower() not in lower:
                            samples_list.append((name, 'VARCHAR'))
                    schema['samples'] = samples_list
                except Exception:
                    pass
                # Print formatted per source
                order = ['samples', 'syscend', 'iorqend', 'kstacks', 'ustacks', 'partitions']
                def norm_type(t: str) -> str:
                    ts = (t or '').upper()
                    if 'CHAR' in ts or 'STRING' in ts or 'VARCHAR' in ts or 'TEXT' in ts:
                        return 'string'
                    if 'INT' in ts:
                        return 'int'
                    if 'DOUBLE' in ts or 'FLOAT' in ts or 'DECIMAL' in ts or 'REAL' in ts:
                        return 'float'
                    if 'BOOL' in ts:
                        return 'bool'
                    if 'TIMESTAMP' in ts or 'DATE' in ts:
                        return 'timestamp'
                    return t.lower()
                NAME_W = 28
                TYPE_W = 9   # standardized type width
                for src in order:
                    cols = schema.get(src, [])
                    if not cols:
                        continue
                    print(src.lower())
                    print('=' * 38)
                    # Sort and normalize
                    rows = [(name.lower(), norm_type(t)) for name, t in cols]
                    rows.sort(key=lambda x: x[0])
                    for name_lc, tnorm in rows:
                        print(f"{name_lc:<{NAME_W}} {tnorm:>{TYPE_W}}")
                    print("")
                # Always include latency section with all available latency columns
                try:
                    from core.query_builder import QueryBuilder as _QB
                    all_latency = set()
                    for key in _QB.COLUMN_SOURCE_MAP.keys():
                        k = key.lower()
                        if k.startswith('sc.') or k == 'sclat_histogram' or k.startswith('io.') or k == 'iolat_histogram':
                            all_latency.add(k)
                    if all_latency:
                        print('latency (--latency)')
                        print('=' * 38)
                        def lat_kind(n: str) -> str:
                            # Infer type: histograms as string, metrics as float
                            return 'string' if 'histogram' in n else 'float'
                        for n in sorted(all_latency):
                            print(f"{n:<{NAME_W}} {lat_kind(n):>{TYPE_W}}")
                        print("")
                except Exception:
                    pass
                return
            
            # Prepare group columns: start from provided -g or defaults, then append -G
            if initial_group_by is None:
                # Use engine defaults
                group_cols = list(engine.DEFAULT_GROUP_COLS.get('dynamic', []))
            else:
                group_cols = list(initial_group_by)
            if append_group_by:
                for col in append_group_by:
                    if col not in group_cols:
                        group_cols.append(col)
            
            # Latency columns
            latency_cols = []
            if args.latency_cols:
                latency_cols = [c.strip().lower() for c in args.latency_cols.split(',') if c.strip()]
            
            # Build params
            active_low, active_high = resolve_active_time_range(
                selection_enabled,
                selection_low_init,
                selection_high_init,
                low_time,
                high_time,
            )

            params = QueryParams(
                where_clause=args.where_clause or '1=1',
                group_cols=group_cols,
                low_time=active_low,
                high_time=active_high,
                limit=args.limit
            )
            
            # Execute
            result = engine.execute_with_params(params, debug=bool(args.debug_log), latency_columns=latency_cols)
            formatter = TableFormatter()

            # Show time range banner in testmode
            print(format_time_range_line(
                "TimeRange",
                low_time,
                high_time,
                label_width=BREADCRUMB_LABEL_WIDTH,
            ))

            if args.out_format == 'table':
                term_width = shutil.get_terminal_size(fallback=(80, 24)).columns
                timeline_line = build_avg_thr_timeline_line(
                    engine,
                    where_clause=params.where_clause,
                    low_time=low_time,
                    high_time=high_time,
                    max_width=term_width,
                    logger=None,
                    highlight_low=params.low_time if selection_enabled else None,
                    highlight_high=params.high_time if selection_enabled else None,
                    render_mode="ansi",
                )
                print(timeline_line)
                if selection_enabled and params.low_time and params.high_time:
                    selection_line = format_time_range_line("Selection Window", params.low_time, params.high_time)
                else:
                    selection_line = "Selection Window: Disabled"
                print(selection_line)
                # Use result.columns order; allow disabling reorder
                text = formatter.format_table(result.data, result.columns, title='Results', reorder=not args.no_reorder)
                print()
                print(text)
            elif args.out_format == 'csv':
                text = formatter.format_csv(result.data, result.columns)
                print(text)
            else:  # json
                text = formatter.format_json(result.data)
                print(text)

            # Handle peek if requested
            if args.peek is not None:
                row_index = args.peek
                if row_index < 0 or row_index >= len(result.data):
                    print(f"Peek row index {row_index} out of range (0..{len(result.data)-1})", file=sys.stderr)
                    return
                row = result.data[row_index]
                columns = result.columns
                
                # Identify peekable columns
                def is_hist_col(name: str) -> bool:
                    n = name.lower()
                    return 'histogram' in n or n.endswith('_hist')
                def is_kstack_col(name: str) -> bool:
                    return name.lower() == 'kstack_hash' or name.lower() == 'kstack_syms'
                def is_ustack_col(name: str) -> bool:
                    return name.lower() == 'ustack_hash' or name.lower() == 'ustack_syms'
                def is_extra_col(name: str) -> bool:
                    return name.lower() == 'extra_info'
                
                def has_meaningful_value(val: Any) -> bool:
                    return val is not None and val != '' and val != '-'
                
                peek_cols = [c for c in columns if (is_hist_col(c) or is_kstack_col(c) or is_ustack_col(c) or is_extra_col(c)) and has_meaningful_value(row.get(c))]
                
                if not peek_cols:
                    # Nothing peekable on this row
                    return
                
                hist_fmt = HistogramFormatter()
                
                if args.out_format == 'table':
                    # Print a human-readable peek per column
                    for col in peek_cols:
                        value = row.get(col)
                        print(f"PEEK: {col} (row {row_index})")
                        if is_hist_col(col):
                            hist_data = hist_fmt.parse_histogram_string(value)
                            if not hist_data:
                                continue
                            # Summary
                            summary = hist_fmt.format_histogram_summary(hist_data)
                            if summary:
                                print(summary)
                            # Chart
                            chart = hist_fmt.format_histogram_chart(hist_data, width=40, show_percentage=True)
                            if chart:
                                print(chart)
                            # Tabular detail
                            table_rows = hist_fmt.format_histogram_table_data(hist_data)
                            # Add est_evts per bucket = count / (bucket_us in seconds)
                            # hist_data entries: (bucket_us, count, est_time_s, global_max)
                            if table_rows:
                                for i, (bucket_us, count, est_time_s, _gm) in enumerate(hist_data[:len(table_rows)]):
                                    if est_time_s and est_time_s > 0:
                                        est_evts = int(count / est_time_s)
                                    else:
                                        est_evts = 0
                                    table_rows[i]['est_evts'] = est_evts
                            if table_rows:
                                # Show only: latency, samples, est_ev/s, est_ev%, est_time%
                                cols = ['latency_range','samples','est_evts','pct_samples','pct_time']
                                headers = {
                                    'latency_range':'latency',
                                    'samples':'samples',
                                    'est_evts':'est_ev/s',
                                    'pct_samples':'est_ev%',
                                    'pct_time':'est_time%'
                                }
                                detail = formatter.format_table(table_rows, cols, headers=headers, title=f"{col} buckets", reorder=False, right_align_all=True)
                                print()
                                print(detail, end='')
                        elif is_kstack_col(col) or is_ustack_col(col):
                            is_kernel = is_kstack_col(col)
                            stack_hash = value
                            # Resolve syms from CSVs
                            try:
                                syms = engine.lookup_stack_trace(stack_hash, is_kernel=is_kernel)
                            except Exception as e:
                                syms = None
                            if not syms:
                                print(f"No stack trace available for hash {stack_hash}")
                                continue
                            frames = [s for s in syms.split(';') if s]
                            # Print top function and frames
                            print(f"hash: {stack_hash}")
                            if frames:
                                print(f"top: {frames[0].split('+')[0]}")
                            for idx, fr in enumerate(frames[:50]):
                                print(f"{idx:2d}: {fr}")
                        elif is_extra_col(col):
                            # Pretty-print JSON from extra_info
                            import json as _json
                            try:
                                obj = _json.loads(value)
                                print(_json.dumps(obj, indent=2, ensure_ascii=False))
                            except Exception as e:
                                print(f"Invalid JSON: {e}")
                                print(str(value))
                        print() 
                elif args.out_format == 'csv':
                    # Append CSV blocks for each peekable histogram
                    for col in peek_cols:
                        value = row.get(col)
                        print("")
                        print(f"# PEEK {col} row={row_index}")
                        if is_hist_col(col):
                            hist_data = hist_fmt.parse_histogram_string(value)
                            if not hist_data:
                                continue
                            # CSV header: bucket_us, samples, est_ev/s, est_ev%, est_time%, est_time_s
                            print("bucket_us,count,est_evts,est_ev_pct,est_time_pct,est_time_s")
                            total_samples = sum(c for _, c, _, _ in hist_data) or 1
                            total_time = sum(t for _, _, t, _ in hist_data) or 1.0
                            for bucket_us, count, est_time, _ in hist_data:
                                pct_samples = (count/total_samples)*100.0
                                pct_time = (est_time/total_time)*100.0
                                est_evts = (count / est_time) if est_time > 0 else 0.0
                                print(f"{bucket_us},{count},{int(est_evts)},{pct_samples:.3f},{pct_time:.3f},{est_time:.6f}")
                        elif is_kstack_col(col) or is_ustack_col(col):
                            is_kernel = is_kstack_col(col)
                            stack_hash = value
                            try:
                                syms = engine.lookup_stack_trace(stack_hash, is_kernel=is_kernel)
                            except Exception:
                                syms = None
                            print("frame,func")
                            if syms:
                                frames = [s for s in syms.split(';') if s]
                                for idx, fr in enumerate(frames):
                                    print(f"{idx},{fr}")
                        elif is_extra_col(col):
                            # Flatten JSON into key,value rows
                            import json as _json
                            def _flatten(prefix, obj, out):
                                if isinstance(obj, dict):
                                    for k, v in obj.items():
                                        _flatten(f"{prefix}.{k}" if prefix else k, v, out)
                                elif isinstance(obj, list):
                                    for i, v in enumerate(obj):
                                        _flatten(f"{prefix}[{i}]", v, out)
                                else:
                                    out.append((prefix, obj))
                            try:
                                obj = _json.loads(value)
                                flat = []
                                _flatten('', obj, flat)
                                print("key,value")
                                for k, v in flat:
                                    # Escape commas and quotes minimally
                                    vs = str(v)
                                    if ',' in vs or '"' in vs:
                                        vs = '"' + vs.replace('"','""') + '"'
                                    print(f"{k},{vs}")
                            except Exception:
                                print("key,value")
                                vs = str(value)
                                if ',' in vs or '"' in vs:
                                    vs = '"' + vs.replace('"','""') + '"'
                                print(f"raw,{vs}")
                else:  # json
                    # Wrap output with peek data as an additional JSON block
                    import json as _json
                    peek_out = {}
                    for col in peek_cols:
                        value = row.get(col)
                        if is_hist_col(col):
                            hist_data = hist_fmt.parse_histogram_string(value)
                            if not hist_data:
                                continue
                            total_samples = sum(c for _, c, _, _ in hist_data) or 1
                            total_time = sum(t for _, _, t, _ in hist_data) or 1.0
                            entries = []
                            for bucket_us, count, est_time, _ in hist_data:
                                entries.append({
                                    'bucket_us': bucket_us,
                                    'count': count,
                                    'est_events_per_s': (count / est_time) if est_time > 0 else 0.0,
                                    'est_time_s': est_time,
                                    'pct_samples': (count/total_samples)*100.0,
                                    'pct_time': (est_time/total_time)*100.0
                                })
                            peek_out[col] = {
                                'row_index': row_index,
                                'histogram': entries
                            }
                        elif is_kstack_col(col) or is_ustack_col(col):
                            is_kernel = is_kstack_col(col)
                            stack_hash = value
                            try:
                                syms = engine.lookup_stack_trace(stack_hash, is_kernel=is_kernel)
                            except Exception:
                                syms = None
                            frames = []
                            if syms:
                                frames = [s for s in syms.split(';') if s]
                            peek_out[col] = {
                                'row_index': row_index,
                                'hash': stack_hash,
                                'frames': frames
                            }
                        elif is_extra_col(col):
                            try:
                                obj = _json.loads(value)
                                peek_out[col] = {
                                    'row_index': row_index,
                                    'json': obj
                                }
                            except Exception as e:
                                peek_out[col] = {
                                    'row_index': row_index,
                                    'raw': value,
                                    'error': str(e)
                                }
                    # Print as a separate JSON object after the main array
                    if peek_out:
                        print(_json.dumps({'peek': peek_out}, indent=2))
            return
        
        # Create and run TUI (removed query argument - always uses dynamic)
        app = XTopTUI(
            args.datadir,
            low_time,
            high_time,
            selection_low_init,
            selection_high_init,
            selection_enabled,
            args.debug_log,
            initial_group_by,
            append_group_by,
            args.duckdb_threads,
        )
        app.run()
        
    except KeyboardInterrupt:
        print("\nInterrupted by user", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == '__main__':
    main()
